Skip to main content

sensitive_rs/engine/
wumanber.rs

1use hashbrown::HashMap;
2#[cfg(feature = "parallel")]
3use rayon::iter::{IndexedParallelIterator, IntoParallelRefIterator, ParallelIterator};
4use smallvec::SmallVec;
5use std::collections::HashSet;
6use std::hash::{DefaultHasher, Hash, Hasher};
7use std::sync::Arc;
8
9/// Space handling policy
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum SpaceHandling {
12    /// Strict match (default): Spaces must match exactly
13    Strict,
14    /// Ignore spaces: Ignore all whitespace characters when matching
15    IgnoreSpaces,
16    /// Normalized spaces: Treat multiple consecutive spaces as a single space
17    NormalizeSpaces,
18}
19
20/// High-performance Wu-Manber multi-pattern matching algorithm
21/// Optimized for Chinese text processing with parallel table building and memory efficiency
22#[derive(Debug, Clone)]
23pub struct WuManber {
24    patterns: Vec<Arc<String>>,
25    original_patterns: Vec<String>,
26    pattern_set: HashSet<Arc<String>>, // For quick lookup
27    min_len: usize,
28    block_size: usize,
29    shift_table: HashMap<u64, usize>,
30    hash_table: HashMap<u64, SmallVec<[usize; 4]>>,
31    space_handling: SpaceHandling,
32}
33
34impl WuManber {
35    /// Create new instance optimized for Chinese characters
36    pub fn new_chinese(patterns: Vec<String>) -> Self {
37        Self::new_with_space_handling(patterns, SpaceHandling::Strict)
38    }
39
40    /// Create new instance with custom block size
41    pub fn new(patterns: Vec<String>, block_size: usize) -> Self {
42        Self::new_with_block_size_and_space_handling(patterns, block_size, SpaceHandling::Strict)
43    }
44
45    /// Create new instance with parallel table building
46    pub fn new_parallel(patterns: Vec<String>, block_size: usize) -> Self {
47        Self::new_parallel_with_space_handling(patterns, block_size, SpaceHandling::Strict)
48    }
49
50    /// Create new instance with space handling strategy
51    pub fn new_with_space_handling(patterns: Vec<String>, space_handling: SpaceHandling) -> Self {
52        let block_size = Self::calculate_optimal_block_size(&patterns, space_handling);
53        Self::new_with_block_size_and_space_handling(patterns, block_size, space_handling)
54    }
55
56    /// Create new instance with custom block size and space handling
57    pub fn new_with_block_size_and_space_handling(
58        patterns: Vec<String>,
59        block_size: usize,
60        space_handling: SpaceHandling,
61    ) -> Self {
62        Self::build_instance(patterns, block_size, false, space_handling)
63    }
64
65    /// Create new instance with parallel table building and space handling
66    pub fn new_parallel_with_space_handling(
67        patterns: Vec<String>,
68        block_size: usize,
69        space_handling: SpaceHandling,
70    ) -> Self {
71        Self::build_instance(patterns, block_size, true, space_handling)
72    }
73
74    /// Calculate optimal block size based on patterns and space handling
75    fn calculate_optimal_block_size(patterns: &[String], space_handling: SpaceHandling) -> usize {
76        if patterns.is_empty() {
77            return 2;
78        }
79
80        let processed_patterns: Vec<String> =
81            patterns.iter().map(|p| Self::preprocess_pattern(p, space_handling)).filter(|p| !p.is_empty()).collect();
82
83        let min_len = processed_patterns.iter().map(|p| p.chars().count()).min().unwrap_or(2);
84
85        // Dynamically adjust block_size to ensure it doesn't exceed the minimum mode length
86        match min_len {
87            1 => 1,
88            2..=3 => 2,
89            4..=10 => 3,
90            _ => (min_len / 2).clamp(2, 4),
91        }
92    }
93
94    /// Preprocess pattern based on space handling strategy
95    fn preprocess_pattern(pattern: &str, space_handling: SpaceHandling) -> String {
96        match space_handling {
97            SpaceHandling::Strict => pattern.to_string(),
98            SpaceHandling::IgnoreSpaces => pattern.chars().filter(|c| !c.is_whitespace()).collect(),
99            SpaceHandling::NormalizeSpaces => {
100                let mut result = String::new();
101                let mut prev_was_space = false;
102
103                for ch in pattern.chars() {
104                    if ch.is_whitespace() {
105                        if !prev_was_space {
106                            result.push(' ');
107                            prev_was_space = true;
108                        }
109                    } else {
110                        result.push(ch);
111                        prev_was_space = false;
112                    }
113                }
114
115                result
116            }
117        }
118    }
119
120    /// Preprocess text based on space handling strategy
121    fn preprocess_text(&self, text: &str) -> String {
122        Self::preprocess_pattern(text, self.space_handling)
123    }
124
125    /// Create empty instance
126    fn empty() -> Self {
127        WuManber {
128            patterns: Vec::new(),
129            original_patterns: Vec::new(),
130            pattern_set: HashSet::new(),
131            min_len: 0,
132            block_size: 2,
133            shift_table: HashMap::new(),
134            hash_table: HashMap::new(),
135            space_handling: SpaceHandling::Strict,
136        }
137    }
138
139    /// Internal instance builder with Arc optimization and space handling
140    fn build_instance(patterns: Vec<String>, block_size: usize, parallel: bool, space_handling: SpaceHandling) -> Self {
141        if patterns.is_empty() {
142            return Self::empty();
143        }
144
145        // Save the original mode
146        let original_patterns = patterns.clone();
147
148        // Pretreatment mode
149        let processed_patterns: Vec<String> =
150            patterns.iter().map(|p| Self::preprocess_pattern(p, space_handling)).filter(|p| !p.is_empty()).collect();
151
152        if processed_patterns.is_empty() {
153            return Self::empty();
154        }
155
156        let min_len = processed_patterns.iter().map(|p| p.chars().count()).min().unwrap_or(1);
157
158        // Make sure that the block_size does not exceed the minimum mode length
159        let safe_block_size = block_size.min(min_len);
160
161        let patterns_arc: Vec<Arc<String>> = processed_patterns.into_iter().map(Arc::new).collect();
162        // Build pattern_set for quick lookups
163        let pattern_set: HashSet<Arc<String>> = patterns_arc.iter().cloned().collect();
164        let mut instance = WuManber {
165            patterns: patterns_arc,
166            original_patterns,
167            pattern_set,
168            min_len,
169            block_size: safe_block_size,
170            shift_table: HashMap::new(),
171            hash_table: HashMap::new(),
172            space_handling,
173        };
174
175        // Parallel table building only when the `parallel` feature is enabled;
176        // otherwise fall back to the sequential builder.
177        #[cfg(feature = "parallel")]
178        {
179            if parallel {
180                instance.build_tables_parallel();
181                return instance;
182            }
183        }
184        #[cfg(not(feature = "parallel"))]
185        let _ = parallel;
186
187        instance.build_tables();
188
189        instance
190    }
191
192    /// Build shift and hash tables sequentially with memory optimization
193    fn build_tables(&mut self) {
194        self.build_shift_table();
195        self.build_hash_table();
196    }
197
198    /// Build shift and hash tables in parallel with memory optimization
199    #[cfg(feature = "parallel")]
200    fn build_tables_parallel(&mut self) {
201        self.build_shift_table_parallel();
202        self.build_hash_table_parallel();
203    }
204
205    /// Build shift table sequentially with optimized character handling
206    fn build_shift_table(&mut self) {
207        for pattern in self.patterns.iter() {
208            let chars: Vec<char> = pattern.chars().collect();
209            let char_count = chars.len();
210
211            // Prevent spillage: Ensure that the pattern length is greater than or equal to block_size
212            if char_count < self.block_size {
213                continue;
214            }
215
216            for i in 0..=(char_count - self.block_size) {
217                let block = self.extract_block_optimized(&chars, i);
218                let hash = Self::calculate_hash_fast(&block);
219                let shift = char_count - i - self.block_size;
220
221                self.shift_table.entry(hash).and_modify(|v| *v = (*v).min(shift)).or_insert(shift);
222            }
223        }
224    }
225
226    /// Build hash table sequentially with memory optimization
227    fn build_hash_table(&mut self) {
228        for (pattern_idx, pattern) in self.patterns.iter().enumerate() {
229            let chars: Vec<char> = pattern.chars().collect();
230            let char_count = chars.len();
231
232            if char_count >= self.block_size {
233                let start_pos = char_count - self.block_size;
234                let block = self.extract_block_optimized(&chars, start_pos);
235                let hash = Self::calculate_hash_fast(&block);
236
237                self.hash_table.entry(hash).or_default().push(pattern_idx);
238            }
239        }
240    }
241
242    /// Build shift table in parallel with proper iterator handling
243    #[cfg(feature = "parallel")]
244    fn build_shift_table_parallel(&mut self) {
245        let block_size = self.block_size;
246
247        let shift_entries: Vec<(u64, usize)> = self
248            .patterns
249            .par_iter()
250            .flat_map(|pattern| {
251                let chars: Vec<char> = pattern.chars().collect();
252                let char_count = chars.len();
253
254                if char_count < block_size {
255                    return Vec::new();
256                }
257
258                (0..=(char_count - block_size))
259                    .map(move |i| {
260                        let block = chars[i..i + block_size].iter().collect::<String>();
261                        let hash = Self::calculate_hash_fast(&block);
262                        let shift = char_count - i - block_size;
263                        (hash, shift)
264                    })
265                    .collect::<Vec<_>>()
266            })
267            .collect();
268
269        for (hash, shift) in shift_entries {
270            self.shift_table.entry(hash).and_modify(|v| *v = (*v).min(shift)).or_insert(shift);
271        }
272    }
273
274    /// Build hash table in parallel with memory optimization
275    #[cfg(feature = "parallel")]
276    fn build_hash_table_parallel(&mut self) {
277        let block_size = self.block_size;
278
279        let hash_entries: Vec<(u64, usize)> = self
280            .patterns
281            .par_iter()
282            .enumerate()
283            .filter_map(|(pattern_idx, pattern)| {
284                let chars: Vec<char> = pattern.chars().collect();
285                let char_count = chars.len();
286
287                if char_count >= block_size {
288                    let start_pos = char_count - block_size;
289                    let block = chars[start_pos..start_pos + block_size].iter().collect::<String>();
290                    let hash = Self::calculate_hash_fast(&block);
291                    Some((hash, pattern_idx))
292                } else {
293                    None
294                }
295            })
296            .collect();
297
298        for (hash, pattern_idx) in hash_entries {
299            self.hash_table.entry(hash).or_insert_with(SmallVec::new).push(pattern_idx);
300        }
301    }
302
303    /// Extract block from character array with reduced allocations
304    #[inline]
305    fn extract_block_optimized(&self, chars: &[char], start: usize) -> String {
306        chars[start..start + self.block_size].iter().collect()
307    }
308
309    /// Optimized hash calculation with better performance
310    #[inline]
311    fn calculate_hash_fast(s: &str) -> u64 {
312        let mut hasher = DefaultHasher::new();
313        s.hash(&mut hasher);
314        hasher.finish()
315    }
316
317    /// Search for first match using Wu-Manber algorithm with space handling
318    pub fn search(&self, text: &str) -> Option<Arc<String>> {
319        if self.patterns.is_empty() || text.is_empty() {
320            return None;
321        }
322
323        if self.space_handling != SpaceHandling::Strict {
324            return self.search_with_preprocessing(text);
325        }
326
327        let chars: Vec<char> = text.chars().collect();
328        let text_len = chars.len();
329
330        if text_len < self.min_len {
331            return None;
332        }
333
334        let mut pos = self.min_len - 1;
335
336        while pos < text_len {
337            if pos + 1 < self.block_size {
338                pos += 1;
339                continue;
340            }
341
342            let block_start = pos + 1 - self.block_size;
343            let block = self.extract_block_optimized(&chars, block_start);
344            let hash = Self::calculate_hash_fast(&block);
345
346            if let Some(&shift) = self.shift_table.get(&hash) {
347                if shift == 0 {
348                    if let Some(pattern_indices) = self.hash_table.get(&hash)
349                        && let Some(result) = self.verify_matches_arc(&chars, pos, pattern_indices)
350                    {
351                        return Some(result);
352                    }
353
354                    let prev_block_start = block_start.saturating_sub(1);
355                    if prev_block_start < block_start {
356                        let prev_block = self.extract_block_optimized(&chars, prev_block_start);
357                        let prev_hash = Self::calculate_hash_fast(&prev_block);
358                        if let Some(found) = self.hash_table.get(&prev_hash).and_then(|prev_pattern_indices| {
359                            self.verify_matches_arc(&chars, pos - 1, prev_pattern_indices)
360                        }) {
361                            return Some(found);
362                        }
363                    }
364
365                    pos += 1;
366                } else {
367                    pos += shift;
368                }
369            } else {
370                // pos += self.min_len;
371                pos += self.min_len.saturating_sub(self.block_size).saturating_add(1);
372            }
373        }
374
375        None
376    }
377
378    /// Search with preprocessing for non-strict space handling
379    fn search_with_preprocessing(&self, text: &str) -> Option<Arc<String>> {
380        for (i, original_pattern) in self.original_patterns.iter().enumerate() {
381            let processed_text = self.preprocess_text(text);
382            let processed_pattern = Self::preprocess_pattern(original_pattern, self.space_handling);
383
384            if processed_text.contains(&processed_pattern) {
385                return self.patterns.get(i).cloned();
386            }
387        }
388        None
389    }
390
391    /// Verify potential matches and return Arc pattern
392    fn verify_matches_arc(&self, chars: &[char], pos: usize, pattern_indices: &[usize]) -> Option<Arc<String>> {
393        for &pattern_idx in pattern_indices {
394            if let Some(pattern) = self.patterns.get(pattern_idx) {
395                let pattern_chars: Vec<char> = pattern.chars().collect();
396                let pattern_len = pattern_chars.len();
397
398                if pos + 1 >= pattern_len {
399                    let start = pos + 1 - pattern_len;
400                    if chars[start..start + pattern_len] == pattern_chars[..] {
401                        return Some(pattern.clone());
402                    }
403                }
404            }
405        }
406        None
407    }
408
409    /// Legacy search method for string compatibility
410    pub fn search_string(&self, text: &str) -> Option<String> {
411        if self.space_handling != SpaceHandling::Strict {
412            // For non-strict matches, use simplified logic to ensure correctness
413            for original_pattern in &self.original_patterns {
414                let processed_text = self.preprocess_text(text);
415                let processed_pattern = Self::preprocess_pattern(original_pattern, self.space_handling);
416                if processed_text.contains(&processed_pattern) {
417                    return Some(original_pattern.clone());
418                }
419            }
420            return None;
421        }
422
423        self.search(text).map(|arc| (*arc).clone())
424    }
425
426    /// Find all matches in text (deduplicated pattern names).
427    pub fn search_all(&self, text: &str) -> Vec<Arc<String>> {
428        if self.patterns.is_empty() || text.is_empty() {
429            return Vec::new();
430        }
431        if self.space_handling != SpaceHandling::Strict {
432            return self.search_all_with_preprocessing(text);
433        }
434
435        let chars: Vec<char> = text.chars().collect();
436        let mut results = Vec::new();
437        let mut found_indices = HashSet::new();
438        self.scan_core(&chars, |pattern_idx, _start, _len| {
439            if found_indices.insert(pattern_idx) {
440                if let Some(pattern) = self.patterns.get(pattern_idx) {
441                    results.push(pattern.clone());
442                }
443            }
444        });
445        results
446    }
447
448    /// Core table-driven scan (Strict mode). Walks the text with the shift/hash
449    /// tables — the same logic `search_all` used to inline — and for every
450    /// verified occurrence (including overlaps) calls
451    /// `on_match(pattern_idx, char_start, char_len)`. The caller decides the
452    /// collect strategy, so `search_all` (dedup by pattern) and `find_matches`
453    /// (collect every position) share one scan implementation instead of two.
454    fn scan_core(&self, chars: &[char], mut on_match: impl FnMut(usize, usize, usize)) {
455        let text_len = chars.len();
456        if text_len < self.min_len {
457            return;
458        }
459        let mut pos = self.min_len - 1;
460        while pos < text_len {
461            if pos + 1 < self.block_size {
462                pos += 1;
463                continue;
464            }
465
466            let block_start = pos + 1 - self.block_size;
467            let block = self.extract_block_optimized(chars, block_start);
468            let hash = Self::calculate_hash_fast(&block);
469
470            if let Some(&shift) = self.shift_table.get(&hash) {
471                if shift == 0 {
472                    if let Some(pattern_indices) = self.hash_table.get(&hash) {
473                        self.verify_and_emit(chars, pos, pattern_indices, &mut on_match);
474                    }
475                    // Overlap handling: re-check the previous block.
476                    let prev_block_start = block_start.saturating_sub(1);
477                    if prev_block_start < block_start {
478                        let prev_block = self.extract_block_optimized(chars, prev_block_start);
479                        let prev_hash = Self::calculate_hash_fast(&prev_block);
480                        if let Some(prev_pattern_indices) = self.hash_table.get(&prev_hash) {
481                            self.verify_and_emit(chars, pos - 1, prev_pattern_indices, &mut on_match);
482                        }
483                    }
484                    pos += 1;
485                } else {
486                    pos += shift;
487                }
488            } else {
489                pos += self.min_len.saturating_sub(self.block_size).saturating_add(1);
490            }
491        }
492    }
493
494    /// Verify candidate patterns whose suffix block sits at `pos` (match ends at
495    /// `pos`, so start = pos+1-len) and emit each that actually matches. No
496    /// dedup — that is the caller's responsibility.
497    fn verify_and_emit(
498        &self,
499        chars: &[char],
500        pos: usize,
501        pattern_indices: &[usize],
502        on_match: &mut impl FnMut(usize, usize, usize),
503    ) {
504        for &pattern_idx in pattern_indices {
505            if let Some(pattern) = self.patterns.get(pattern_idx) {
506                let pattern_chars: Vec<char> = pattern.chars().collect();
507                let pattern_len = pattern_chars.len();
508                if pos + 1 >= pattern_len {
509                    let start = pos + 1 - pattern_len;
510                    if chars[start..start + pattern_len] == pattern_chars[..] {
511                        on_match(pattern_idx, start, pattern_len);
512                    }
513                }
514            }
515        }
516    }
517
518    /// Search all with preprocessing for non-strict space handling
519    fn search_all_with_preprocessing(&self, text: &str) -> Vec<Arc<String>> {
520        let mut results = Vec::new();
521        let mut found_indices = HashSet::new();
522        let processed_text = self.preprocess_text(text);
523
524        for (i, original_pattern) in self.original_patterns.iter().enumerate() {
525            let processed_pattern = Self::preprocess_pattern(original_pattern, self.space_handling);
526
527            if processed_text.contains(&processed_pattern) && !found_indices.contains(&i) {
528                found_indices.insert(i);
529                if i < self.patterns.len() {
530                    results.push(self.patterns[i].clone());
531                }
532            }
533        }
534
535        results
536    }
537
538    /// Find all matches returning strings for compatibility
539    pub fn search_all_strings(&self, text: &str) -> Vec<String> {
540        self.search_all(text).iter().map(|arc| (**arc).clone()).collect()
541    }
542
543    /// Replace all matches with `replacement`, one char per matched character.
544    /// Overlapping patterns resolve leftmost-longest (e.g. with both "赌博" and
545    /// "赌博机" in the dictionary, "赌博机" wins and becomes "***").
546    pub fn replace_all(&self, text: &str, replacement: char) -> String {
547        let repl = replacement.to_string();
548        self.rebuild_matches(text, |start, end| {
549            let char_count = text[start..end].chars().count();
550            repl.repeat(char_count)
551        })
552    }
553
554    /// Remove all matches from text (leftmost-longest on overlap).
555    pub fn remove_all(&self, text: &str) -> String {
556        self.rebuild_matches(text, |_, _| String::new())
557    }
558
559    /// Single-pass rebuild: locate every match via the table-driven
560    /// `find_matches`, order them leftmost-longest, then walk the text once,
561    /// copying non-match spans verbatim and substituting match spans. Spans
562    /// overlapped by an earlier (longer-leftmost) match are skipped.
563    fn rebuild_matches(&self, text: &str, make_replacement: impl Fn(usize, usize) -> String) -> String {
564        let mut matches = self.find_matches(text);
565        // start asc; for equal start, longer span first (so it wins on overlap)
566        matches.sort_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end)));
567
568        let mut out = String::with_capacity(text.len());
569        let mut cursor = 0usize;
570        for m in matches {
571            if m.start < cursor {
572                continue; // covered by a previously kept span
573            }
574            out.push_str(&text[cursor..m.start]);
575            out.push_str(&make_replacement(m.start, m.end));
576            cursor = m.end;
577        }
578        out.push_str(&text[cursor..]);
579        out
580    }
581
582    /// Find all match positions with byte offsets.
583    ///
584    /// Strict mode uses the shift/hash tables via `scan_core` (shared with
585    /// `search_all`); char indices are translated to byte offsets with an O(n)
586    /// `char_indices` lookup so `Match` keeps its byte-offset contract. Non-strict
587    /// space handling falls back to a byte-level brute-force scan
588    /// (`Filter`/`MultiPatternEngine` always use Strict).
589    pub fn find_matches(&self, text: &str) -> Vec<Match> {
590        if self.space_handling != SpaceHandling::Strict || self.patterns.is_empty() || text.is_empty() {
591            return self.find_matches_bruteforce(text);
592        }
593
594        let chars: Vec<char> = text.chars().collect();
595        // char index -> byte offset (O(n) once). A trailing sentinel = text.len()
596        // lets a match ending at the last char resolve to the end of the text.
597        let mut char_to_byte: Vec<usize> = text.char_indices().map(|(b, _)| b).collect();
598        char_to_byte.push(text.len());
599
600        let mut matches = Vec::new();
601        let mut seen = HashSet::new();
602        self.scan_core(&chars, |_pattern_idx, char_start, char_len| {
603            let char_end = char_start + char_len;
604            // scan_core's overlap re-check can emit the same (pattern, position)
605            // twice; dedup by char span so each occurrence is reported once.
606            if seen.insert((char_start, char_end)) {
607                matches.push(Match { start: char_to_byte[char_start], end: char_to_byte[char_end] });
608            }
609        });
610        matches.sort_by_key(|m| m.start);
611        matches
612    }
613
614    /// Byte-level brute-force fallback used for non-strict space handling.
615    fn find_matches_bruteforce(&self, text: &str) -> Vec<Match> {
616        let mut matches = Vec::new();
617        for pattern in &self.original_patterns {
618            let mut start = 0;
619            while let Some(pos) = text[start..].find(pattern) {
620                let absolute_start = start + pos;
621                let absolute_end = absolute_start + pattern.len();
622                matches.push(Match { start: absolute_start, end: absolute_end });
623                start = absolute_start + text[absolute_start..].chars().next().map_or(1, char::len_utf8);
624            }
625        }
626        matches.sort_by_key(|m| m.start);
627        matches
628    }
629
630    /// Get patterns reference (returns Arc slice)
631    pub fn patterns(&self) -> &[Arc<String>] {
632        &self.patterns
633    }
634
635    /// Get original patterns as strings
636    pub fn patterns_strings(&self) -> Vec<String> {
637        self.original_patterns.clone()
638    }
639
640    /// Check if pattern exists (使用 pattern_set 提供 O(1) 查找)
641    pub fn contains_pattern(&self, pattern: &str) -> bool {
642        let target = Arc::new(pattern.to_string());
643        self.pattern_set.contains(&target)
644    }
645
646    /// Get space handling strategy
647    pub fn space_handling(&self) -> SpaceHandling {
648        self.space_handling
649    }
650
651    /// Get memory usage statistics
652    pub fn memory_stats(&self) -> WuManberMemoryStats {
653        let patterns_memory = self.patterns.iter().map(|p| size_of::<Arc<String>>() + p.len()).sum::<usize>();
654
655        let shift_table_memory = self.shift_table.len() * (size_of::<u64>() + size_of::<usize>());
656        let hash_table_memory = self
657            .hash_table
658            .iter()
659            .map(|(_k, v)| size_of::<u64>() + size_of::<SmallVec<[usize; 4]>>() + v.len() * size_of::<usize>())
660            .sum::<usize>();
661
662        let pattern_set_memory = self.pattern_set.len() * size_of::<Arc<String>>();
663
664        let total_memory = patterns_memory + shift_table_memory + hash_table_memory + pattern_set_memory;
665
666        WuManberMemoryStats {
667            total_patterns: self.patterns.len(),
668            patterns_memory,
669            shift_table_memory,
670            hash_table_memory,
671            total_memory,
672        }
673    }
674
675    /// Get statistics
676    pub fn stats(&self) -> WuManberStats {
677        WuManberStats {
678            pattern_count: self.patterns.len(),
679            min_length: self.min_len,
680            block_size: self.block_size,
681            shift_table_size: self.shift_table.len(),
682            hash_table_size: self.hash_table.len(),
683        }
684    }
685}
686
687/// Match result with byte positions
688#[derive(Debug, Clone, Copy, PartialEq, Eq)]
689pub struct Match {
690    pub start: usize,
691    pub end: usize,
692}
693
694/// Wu-Manber statistics
695#[derive(Debug, Clone)]
696pub struct WuManberStats {
697    pub pattern_count: usize,
698    pub min_length: usize,
699    pub block_size: usize,
700    pub shift_table_size: usize,
701    pub hash_table_size: usize,
702}
703
704/// Wu-Manber memory usage statistics
705#[derive(Debug, Clone)]
706pub struct WuManberMemoryStats {
707    pub total_patterns: usize,
708    pub patterns_memory: usize,
709    pub shift_table_memory: usize,
710    pub hash_table_memory: usize,
711    pub total_memory: usize,
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    #[test]
719    fn test_basic_matching() {
720        let wm =
721            WuManber::new_chinese(vec!["色情".to_string(), "赌博".to_string(), "诈骗".to_string(), "扯蛋".to_string()]);
722
723        assert_eq!(wm.search_string("正常内容"), None);
724        assert_eq!(wm.search_string("测试含有赌博内容"), Some("赌博".to_string()));
725        assert_eq!(wm.search_string("测试还有色情赌博图片"), Some("色情".to_string()));
726        assert_eq!(wm.search_string("测试有色情赌博图片"), Some("色情".to_string()));
727        assert_eq!(wm.search_string("还有诈骗色情测试"), Some("诈骗".to_string()));
728    }
729
730    #[test]
731    fn test_varied_length() {
732        let wm = WuManber::new(vec!["赌".to_string(), "赌博".to_string(), "赌博机".to_string()], 1);
733
734        assert_eq!(wm.search_string("赌"), Some("赌".to_string()));
735        assert_eq!(wm.search_string("赌博"), Some("赌".to_string())); // 会匹配最先找到的
736        assert_eq!(wm.search_string("赌博机"), Some("赌".to_string())); // 会匹配最先找到的
737    }
738
739    #[test]
740    fn test_space_handling_strategies() {
741        let patterns = vec!["hello world".to_string(), "test  pattern".to_string()];
742
743        // 严格匹配
744        let wm_strict = WuManber::new_with_space_handling(patterns.clone(), SpaceHandling::Strict);
745        assert_eq!(wm_strict.search_string("hello world"), Some("hello world".to_string()));
746        assert_eq!(wm_strict.search_string("helloworld"), None);
747
748        // 忽略空格
749        let wm_ignore = WuManber::new_with_space_handling(patterns.clone(), SpaceHandling::IgnoreSpaces);
750        assert_eq!(wm_ignore.search_string("hello world"), Some("hello world".to_string()));
751        assert_eq!(wm_ignore.search_string("helloworld"), Some("hello world".to_string()));
752
753        // 标准化空格
754        let wm_normalize = WuManber::new_with_space_handling(patterns.clone(), SpaceHandling::NormalizeSpaces);
755        assert_eq!(wm_normalize.search_string("test  pattern"), Some("test  pattern".to_string()));
756        assert_eq!(wm_normalize.search_string("test   pattern"), Some("test  pattern".to_string()));
757    }
758
759    #[test]
760    fn test_mixed_patterns() {
761        let mixed_patterns =
762            vec!["关键词 2500".to_string(), "关键词 3000".to_string(), "test".to_string(), "hello world".to_string()];
763
764        let wm = WuManber::new_chinese(mixed_patterns);
765
766        assert_eq!(wm.search_string("包含关键词 2500 的文本"), Some("关键词 2500".to_string()));
767        assert_eq!(wm.search_string("包含关键词 3000 的文本"), Some("关键词 3000".to_string()));
768        assert_eq!(wm.search_string("this is test"), Some("test".to_string()));
769        assert_eq!(wm.search_string("say hello world"), Some("hello world".to_string()));
770    }
771
772    #[test]
773    fn test_complex_patterns_with_spaces() {
774        let complex_patterns = vec![
775            "Hello World".to_string(),
776            "你好 世界".to_string(),
777            "multiple   spaces".to_string(),
778            "tab\there".to_string(),
779            "new\nline".to_string(),
780        ];
781
782        let wm = WuManber::new_chinese(complex_patterns);
783
784        assert_eq!(wm.search_string("Say Hello World"), Some("Hello World".to_string()));
785        assert_eq!(wm.search_string("说你好 世界"), Some("你好 世界".to_string()));
786        assert_eq!(wm.search_string("has multiple   spaces here"), Some("multiple   spaces".to_string()));
787        assert_eq!(wm.search_string("tab\there you go"), Some("tab\there".to_string()));
788        assert_eq!(wm.search_string("new\nline break"), Some("new\nline".to_string()));
789    }
790
791    #[test]
792    fn test_ignore_spaces_functionality() {
793        let patterns = vec!["关键词 2500".to_string(), "hello world".to_string()];
794        let wm = WuManber::new_with_space_handling(patterns, SpaceHandling::IgnoreSpaces);
795
796        // 这些都应该匹配
797        assert_eq!(wm.search_string("关键词 2500"), Some("关键词 2500".to_string()));
798        assert_eq!(wm.search_string("关键词 2500"), Some("关键词 2500".to_string()));
799        assert_eq!(wm.search_string("关键词  2500"), Some("关键词 2500".to_string()));
800        assert_eq!(wm.search_string("helloworld"), Some("hello world".to_string()));
801        assert_eq!(wm.search_string("hello world"), Some("hello world".to_string()));
802    }
803
804    #[test]
805    fn test_parallel_performance() {
806        let patterns: Vec<String> = (0..5000).map(|i| format!("关键词 {i}")).collect();
807
808        let wm_seq = WuManber::new(patterns.clone(), 2);
809        let wm_par = WuManber::new_parallel(patterns, 2);
810
811        let text = "这里包含关键词 2500 和关键词 3000";
812
813        let result_seq = wm_seq.search_all_strings(text);
814        let result_par = wm_par.search_all_strings(text);
815
816        assert_eq!(result_seq.len(), result_par.len());
817        assert!(result_seq.contains(&"关键词 2500".to_string()));
818        assert!(result_seq.contains(&"关键词 3000".to_string()));
819
820        for item in &result_seq {
821            assert!(result_par.contains(item));
822        }
823    }
824
825    #[test]
826    fn test_search_all_functionality() {
827        let wm = WuManber::new_chinese(vec!["苹果".to_string(), "香蕉".to_string(), "橙子".to_string()]);
828
829        let text = "我喜欢吃苹果、香蕉和橙子";
830        let results = wm.search_all_strings(text);
831
832        assert_eq!(results.len(), 3);
833        assert!(results.contains(&"苹果".to_string()));
834        assert!(results.contains(&"香蕉".to_string()));
835        assert!(results.contains(&"橙子".to_string()));
836    }
837
838    #[test]
839    fn test_find_matches_multibyte_positions() {
840        // Regression: find_matches used to panic on multi-byte text because the
841        // scan cursor advanced by 1 byte instead of 1 character.
842        let wm = WuManber::new_chinese(vec!["赌博".to_string()]);
843
844        let text = "含有赌博内容";
845        let matches = wm.find_matches(text);
846        assert_eq!(matches.len(), 1);
847        assert_eq!(matches[0].start, 6); // "含有" = 6 bytes
848        assert_eq!(matches[0].end, 12); // "赌博" = 6 bytes
849        assert_eq!(&text[matches[0].start..matches[0].end], "赌博");
850    }
851
852    #[test]
853    fn test_find_matches_multiple_multibyte_occurrences() {
854        // Two non-overlapping occurrences must both be reported without panicking.
855        let wm = WuManber::new_chinese(vec!["赌博".to_string()]);
856        let text = "赌博和赌博";
857        let matches = wm.find_matches(text);
858        assert_eq!(matches.len(), 2);
859        for m in &matches {
860            assert_eq!(&text[m.start..m.end], "赌博");
861        }
862    }
863
864    #[test]
865    fn test_find_matches_mixed_ascii_and_multibyte() {
866        // Mixed-script text with multiple matches must not panic.
867        let wm = WuManber::new_chinese(vec!["ab".to_string(), "赌博".to_string()]);
868        let text = "x 赌博 y 赌博 z";
869        let matches = wm.find_matches(text);
870        assert_eq!(matches.len(), 2);
871        assert!(matches.iter().all(|m| &text[m.start..m.end] == "赌博"));
872    }
873
874    #[test]
875    fn test_find_matches_overlapping_patterns() {
876        // Overlapping patterns: "赌博" ⊂ "赌博机" — both match at start=0, but
877        // each occurrence must be reported exactly once (scan_core's overlap
878        // re-check must not double-count the shorter pattern).
879        let wm = WuManber::new_chinese(vec!["赌博".to_string(), "赌博机".to_string()]);
880        let text = "赌博机";
881        let matches = wm.find_matches(text);
882        assert_eq!(matches.len(), 2, "got {matches:?}");
883        assert!(matches.iter().any(|m| &text[m.start..m.end] == "赌博"));
884        assert!(matches.iter().any(|m| &text[m.start..m.end] == "赌博机"));
885        // No duplicate spans.
886        let mut spans: Vec<_> = matches.iter().map(|m| (m.start, m.end)).collect();
887        spans.sort();
888        let before = spans.len();
889        spans.dedup();
890        assert_eq!(spans.len(), before);
891    }
892
893    #[test]
894    fn test_find_matches_pattern_at_text_end() {
895        // Pattern ending exactly at the text boundary exercises the
896        // char_to_byte sentinel (char_end == chars.len() -> text.len()).
897        let wm = WuManber::new_chinese(vec!["赌博".to_string()]);
898        let text = "前缀赌博";
899        let matches = wm.find_matches(text);
900        assert_eq!(matches.len(), 1);
901        assert_eq!(&text[matches[0].start..matches[0].end], "赌博");
902        assert_eq!(matches[0].end, text.len());
903    }
904
905    #[test]
906    fn test_replace_all_overlapping_leftmost_longest() {
907        // 赌 ⊂ 赌博 ⊂ 赌博机:leftmost-longest replaces the whole "赌博机" (3 chars).
908        let wm = WuManber::new_chinese(vec!["赌".to_string(), "赌博".to_string(), "赌博机".to_string()]);
909        assert_eq!(wm.replace_all("赌博机", '*'), "***");
910        assert_eq!(wm.remove_all("赌博机"), "");
911    }
912
913    #[test]
914    fn test_replace_all_multiple_distinct() {
915        let wm = WuManber::new_chinese(vec!["赌博".to_string(), "色情".to_string()]);
916        // Each match replaced by one char per matched character.
917        assert_eq!(wm.replace_all("赌博和色情", '*'), "**和**");
918    }
919
920    #[test]
921    fn test_replace_all_no_match() {
922        let wm = WuManber::new_chinese(vec!["赌博".to_string()]);
923        assert_eq!(wm.replace_all("正常内容", '*'), "正常内容");
924        assert_eq!(wm.remove_all("正常内容"), "正常内容");
925    }
926}