Skip to main content

ipfrs_semantic/
tokenizer.rs

1//! Semantic Tokenizer — text tokenization for semantic search indexing.
2//!
3//! Provides configurable tokenization with whitespace, word boundary, and n-gram modes,
4//! stop-word filtering, length constraints, and normalization.
5
6use std::fmt;
7
8// ---------------------------------------------------------------------------
9// Types
10// ---------------------------------------------------------------------------
11
12/// Tokenization strategy.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum TokenizerMode {
15    /// Split on whitespace characters.
16    Whitespace,
17    /// Split on non-alphanumeric boundaries.
18    WordBoundary,
19    /// Generate character n-grams of a fixed size.
20    NGram,
21}
22
23impl fmt::Display for TokenizerMode {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        match self {
26            Self::Whitespace => write!(f, "Whitespace"),
27            Self::WordBoundary => write!(f, "WordBoundary"),
28            Self::NGram => write!(f, "NGram"),
29        }
30    }
31}
32
33/// Configuration for [`SemanticTokenizer`].
34#[derive(Debug, Clone)]
35pub struct TokenizerConfig {
36    /// Tokenization mode.
37    pub mode: TokenizerMode,
38    /// Whether to lowercase all tokens (default `true`).
39    pub lowercase: bool,
40    /// Minimum token length to keep (default `1`).
41    pub min_token_length: usize,
42    /// Maximum token length to keep (default `100`).
43    pub max_token_length: usize,
44    /// Character n-gram size (only used with [`TokenizerMode::NGram`], default `3`).
45    pub ngram_size: usize,
46    /// Words to exclude from output.
47    pub stop_words: Vec<String>,
48}
49
50impl Default for TokenizerConfig {
51    fn default() -> Self {
52        Self {
53            mode: TokenizerMode::Whitespace,
54            lowercase: true,
55            min_token_length: 1,
56            max_token_length: 100,
57            ngram_size: 3,
58            stop_words: Vec::new(),
59        }
60    }
61}
62
63/// A single token produced by the tokenizer.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct Token {
66    /// The (possibly lowercased) token text.
67    pub text: String,
68    /// Zero-based position among emitted tokens in the source text.
69    pub position: usize,
70    /// Byte offset where this token begins in the original text.
71    pub byte_offset: usize,
72}
73
74/// Runtime statistics for a [`SemanticTokenizer`].
75#[derive(Debug, Clone)]
76pub struct TokenizerStats {
77    /// Active tokenization mode.
78    pub mode: TokenizerMode,
79    /// Cumulative number of tokens produced.
80    pub tokens_produced: u64,
81    /// Number of texts that have been tokenized.
82    pub texts_tokenized: u64,
83    /// Current size of the stop-word list.
84    pub stop_words_count: usize,
85}
86
87// ---------------------------------------------------------------------------
88// SemanticTokenizer
89// ---------------------------------------------------------------------------
90
91/// A configurable tokenizer for semantic search indexing.
92///
93/// Supports whitespace splitting, word-boundary splitting, and character n-gram
94/// generation. Tokens can be lowercased, length-filtered, and stop-word filtered.
95pub struct SemanticTokenizer {
96    config: TokenizerConfig,
97    tokens_produced: u64,
98    texts_tokenized: u64,
99}
100
101impl SemanticTokenizer {
102    /// Create a new tokenizer with the given configuration.
103    pub fn new(config: TokenizerConfig) -> Self {
104        Self {
105            config,
106            tokens_produced: 0,
107            texts_tokenized: 0,
108        }
109    }
110
111    /// Tokenize `text` according to the current mode and filters.
112    pub fn tokenize(&mut self, text: &str) -> Vec<Token> {
113        let raw = self.raw_segments(text);
114        let tokens = self.apply_filters(raw);
115        self.texts_tokenized += 1;
116        self.tokens_produced += tokens.len() as u64;
117        tokens
118    }
119
120    /// Tokenize a batch of texts.
121    pub fn tokenize_batch(&mut self, texts: &[&str]) -> Vec<Vec<Token>> {
122        texts.iter().map(|t| self.tokenize(t)).collect()
123    }
124
125    /// Return the number of tokens that would be produced **without** allocating
126    /// full `Token` structs.
127    pub fn token_count(&self, text: &str) -> usize {
128        let raw = self.raw_segments(text);
129        self.count_filtered(&raw)
130    }
131
132    /// Add a stop word.
133    pub fn add_stop_word(&mut self, word: &str) {
134        let w = if self.config.lowercase {
135            word.to_lowercase()
136        } else {
137            word.to_string()
138        };
139        if !self.config.stop_words.contains(&w) {
140            self.config.stop_words.push(w);
141        }
142    }
143
144    /// Remove a stop word. Returns `true` if it was present.
145    pub fn remove_stop_word(&mut self, word: &str) -> bool {
146        let w = if self.config.lowercase {
147            word.to_lowercase()
148        } else {
149            word.to_string()
150        };
151        if let Some(pos) = self.config.stop_words.iter().position(|s| s == &w) {
152            self.config.stop_words.remove(pos);
153            true
154        } else {
155            false
156        }
157    }
158
159    /// Check whether `word` is in the stop-word list.
160    pub fn is_stop_word(&self, word: &str) -> bool {
161        let w = if self.config.lowercase {
162            word.to_lowercase()
163        } else {
164            word.to_string()
165        };
166        self.config.stop_words.contains(&w)
167    }
168
169    /// Normalize `text` by applying lowercasing (if configured).
170    pub fn normalize(&self, text: &str) -> String {
171        if self.config.lowercase {
172            text.to_lowercase()
173        } else {
174            text.to_string()
175        }
176    }
177
178    /// Return current tokenizer statistics.
179    pub fn stats(&self) -> TokenizerStats {
180        TokenizerStats {
181            mode: self.config.mode,
182            tokens_produced: self.tokens_produced,
183            texts_tokenized: self.texts_tokenized,
184            stop_words_count: self.config.stop_words.len(),
185        }
186    }
187
188    // -----------------------------------------------------------------------
189    // Internal helpers
190    // -----------------------------------------------------------------------
191
192    /// A raw segment: (start_byte_offset, text_slice).
193    fn raw_segments<'a>(&self, text: &'a str) -> Vec<(usize, &'a str)> {
194        match self.config.mode {
195            TokenizerMode::Whitespace => self.split_whitespace(text),
196            TokenizerMode::WordBoundary => self.split_word_boundary(text),
197            TokenizerMode::NGram => self.split_ngrams(text),
198        }
199    }
200
201    fn split_whitespace<'a>(&self, text: &'a str) -> Vec<(usize, &'a str)> {
202        let mut segments = Vec::new();
203        let mut in_word = false;
204        let mut start = 0;
205        for (i, ch) in text.char_indices() {
206            if ch.is_whitespace() {
207                if in_word {
208                    segments.push((start, &text[start..i]));
209                    in_word = false;
210                }
211            } else {
212                if !in_word {
213                    start = i;
214                    in_word = true;
215                }
216            }
217        }
218        if in_word {
219            segments.push((start, &text[start..]));
220        }
221        segments
222    }
223
224    fn split_word_boundary<'a>(&self, text: &'a str) -> Vec<(usize, &'a str)> {
225        let mut segments = Vec::new();
226        let mut in_word = false;
227        let mut start = 0;
228        for (i, ch) in text.char_indices() {
229            if ch.is_alphanumeric() || ch == '_' {
230                if !in_word {
231                    start = i;
232                    in_word = true;
233                }
234            } else {
235                if in_word {
236                    segments.push((start, &text[start..i]));
237                    in_word = false;
238                }
239            }
240        }
241        if in_word {
242            segments.push((start, &text[start..]));
243        }
244        segments
245    }
246
247    fn split_ngrams<'a>(&self, text: &'a str) -> Vec<(usize, &'a str)> {
248        let n = self.config.ngram_size;
249        if n == 0 {
250            return Vec::new();
251        }
252        let chars: Vec<(usize, char)> = text.char_indices().collect();
253        if chars.len() < n {
254            return Vec::new();
255        }
256        let mut segments = Vec::new();
257        for window_start in 0..=(chars.len() - n) {
258            let byte_start = chars[window_start].0;
259            let byte_end = if window_start + n < chars.len() {
260                chars[window_start + n].0
261            } else {
262                text.len()
263            };
264            segments.push((byte_start, &text[byte_start..byte_end]));
265        }
266        segments
267    }
268
269    /// Apply lowercase, length, and stop-word filters and assign positions.
270    fn apply_filters(&self, raw: Vec<(usize, &str)>) -> Vec<Token> {
271        let mut tokens = Vec::new();
272        let mut position = 0usize;
273        for (byte_offset, slice) in raw {
274            let text = if self.config.lowercase {
275                slice.to_lowercase()
276            } else {
277                slice.to_string()
278            };
279            let len = text.len();
280            if len < self.config.min_token_length || len > self.config.max_token_length {
281                continue;
282            }
283            if self.config.stop_words.contains(&text) {
284                continue;
285            }
286            tokens.push(Token {
287                text,
288                position,
289                byte_offset,
290            });
291            position += 1;
292        }
293        tokens
294    }
295
296    /// Count tokens that pass filters without allocating Token structs.
297    fn count_filtered(&self, raw: &[(usize, &str)]) -> usize {
298        let mut count = 0usize;
299        for (_offset, slice) in raw {
300            let text = if self.config.lowercase {
301                slice.to_lowercase()
302            } else {
303                (*slice).to_string()
304            };
305            let len = text.len();
306            if len < self.config.min_token_length || len > self.config.max_token_length {
307                continue;
308            }
309            if self.config.stop_words.contains(&text) {
310                continue;
311            }
312            count += 1;
313        }
314        count
315    }
316}
317
318// ---------------------------------------------------------------------------
319// Tests
320// ---------------------------------------------------------------------------
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    fn default_tokenizer() -> SemanticTokenizer {
327        SemanticTokenizer::new(TokenizerConfig::default())
328    }
329
330    // -- Whitespace mode ---------------------------------------------------
331
332    #[test]
333    fn whitespace_basic() {
334        let mut tok = default_tokenizer();
335        let tokens = tok.tokenize("hello world");
336        assert_eq!(tokens.len(), 2);
337        assert_eq!(tokens[0].text, "hello");
338        assert_eq!(tokens[1].text, "world");
339    }
340
341    #[test]
342    fn whitespace_multiple_spaces() {
343        let mut tok = default_tokenizer();
344        let tokens = tok.tokenize("a   b\t\nc");
345        assert_eq!(tokens.len(), 3);
346        assert_eq!(tokens[0].text, "a");
347        assert_eq!(tokens[1].text, "b");
348        assert_eq!(tokens[2].text, "c");
349    }
350
351    #[test]
352    fn whitespace_preserves_punctuation() {
353        let mut tok = default_tokenizer();
354        let tokens = tok.tokenize("hello, world!");
355        assert_eq!(tokens.len(), 2);
356        assert_eq!(tokens[0].text, "hello,");
357        assert_eq!(tokens[1].text, "world!");
358    }
359
360    #[test]
361    fn whitespace_leading_trailing() {
362        let mut tok = default_tokenizer();
363        let tokens = tok.tokenize("  hello  ");
364        assert_eq!(tokens.len(), 1);
365        assert_eq!(tokens[0].text, "hello");
366        assert_eq!(tokens[0].byte_offset, 2);
367    }
368
369    // -- Word boundary mode ------------------------------------------------
370
371    #[test]
372    fn word_boundary_basic() {
373        let mut tok = SemanticTokenizer::new(TokenizerConfig {
374            mode: TokenizerMode::WordBoundary,
375            ..Default::default()
376        });
377        let tokens = tok.tokenize("hello, world!");
378        assert_eq!(tokens.len(), 2);
379        assert_eq!(tokens[0].text, "hello");
380        assert_eq!(tokens[1].text, "world");
381    }
382
383    #[test]
384    fn word_boundary_underscore() {
385        let mut tok = SemanticTokenizer::new(TokenizerConfig {
386            mode: TokenizerMode::WordBoundary,
387            ..Default::default()
388        });
389        let tokens = tok.tokenize("foo_bar baz");
390        assert_eq!(tokens.len(), 2);
391        assert_eq!(tokens[0].text, "foo_bar");
392        assert_eq!(tokens[1].text, "baz");
393    }
394
395    #[test]
396    fn word_boundary_numbers() {
397        let mut tok = SemanticTokenizer::new(TokenizerConfig {
398            mode: TokenizerMode::WordBoundary,
399            ..Default::default()
400        });
401        let tokens = tok.tokenize("v2.0-alpha");
402        assert_eq!(tokens.len(), 3);
403        assert_eq!(tokens[0].text, "v2");
404        assert_eq!(tokens[1].text, "0");
405        assert_eq!(tokens[2].text, "alpha");
406    }
407
408    #[test]
409    fn word_boundary_all_punctuation() {
410        let mut tok = SemanticTokenizer::new(TokenizerConfig {
411            mode: TokenizerMode::WordBoundary,
412            ..Default::default()
413        });
414        let tokens = tok.tokenize("...---!!!");
415        assert!(tokens.is_empty());
416    }
417
418    // -- NGram mode --------------------------------------------------------
419
420    #[test]
421    fn ngram_basic() {
422        let mut tok = SemanticTokenizer::new(TokenizerConfig {
423            mode: TokenizerMode::NGram,
424            ngram_size: 3,
425            ..Default::default()
426        });
427        let tokens = tok.tokenize("hello");
428        // 5 chars => 3 trigrams: hel, ell, llo
429        assert_eq!(tokens.len(), 3);
430        assert_eq!(tokens[0].text, "hel");
431        assert_eq!(tokens[1].text, "ell");
432        assert_eq!(tokens[2].text, "llo");
433    }
434
435    #[test]
436    fn ngram_too_short() {
437        let mut tok = SemanticTokenizer::new(TokenizerConfig {
438            mode: TokenizerMode::NGram,
439            ngram_size: 5,
440            ..Default::default()
441        });
442        let tokens = tok.tokenize("hi");
443        assert!(tokens.is_empty());
444    }
445
446    #[test]
447    fn ngram_exact_length() {
448        let mut tok = SemanticTokenizer::new(TokenizerConfig {
449            mode: TokenizerMode::NGram,
450            ngram_size: 3,
451            ..Default::default()
452        });
453        let tokens = tok.tokenize("abc");
454        assert_eq!(tokens.len(), 1);
455        assert_eq!(tokens[0].text, "abc");
456    }
457
458    #[test]
459    fn ngram_size_one() {
460        let mut tok = SemanticTokenizer::new(TokenizerConfig {
461            mode: TokenizerMode::NGram,
462            ngram_size: 1,
463            ..Default::default()
464        });
465        let tokens = tok.tokenize("hi");
466        assert_eq!(tokens.len(), 2);
467        assert_eq!(tokens[0].text, "h");
468        assert_eq!(tokens[1].text, "i");
469    }
470
471    #[test]
472    fn ngram_size_zero() {
473        let mut tok = SemanticTokenizer::new(TokenizerConfig {
474            mode: TokenizerMode::NGram,
475            ngram_size: 0,
476            ..Default::default()
477        });
478        let tokens = tok.tokenize("anything");
479        assert!(tokens.is_empty());
480    }
481
482    #[test]
483    fn ngram_unicode() {
484        let mut tok = SemanticTokenizer::new(TokenizerConfig {
485            mode: TokenizerMode::NGram,
486            ngram_size: 2,
487            lowercase: false,
488            ..Default::default()
489        });
490        let tokens = tok.tokenize("café");
491        assert_eq!(tokens.len(), 3); // ca, af, fé
492        assert_eq!(tokens[0].text, "ca");
493        assert_eq!(tokens[2].text, "fé");
494    }
495
496    // -- Lowercase ---------------------------------------------------------
497
498    #[test]
499    fn lowercase_normalization() {
500        let mut tok = default_tokenizer();
501        let tokens = tok.tokenize("Hello WORLD FoO");
502        assert_eq!(tokens[0].text, "hello");
503        assert_eq!(tokens[1].text, "world");
504        assert_eq!(tokens[2].text, "foo");
505    }
506
507    #[test]
508    fn lowercase_disabled() {
509        let mut tok = SemanticTokenizer::new(TokenizerConfig {
510            lowercase: false,
511            ..Default::default()
512        });
513        let tokens = tok.tokenize("Hello WORLD");
514        assert_eq!(tokens[0].text, "Hello");
515        assert_eq!(tokens[1].text, "WORLD");
516    }
517
518    // -- Stop words --------------------------------------------------------
519
520    #[test]
521    fn stop_words_filtering() {
522        let mut tok = SemanticTokenizer::new(TokenizerConfig {
523            stop_words: vec!["the".into(), "a".into(), "is".into()],
524            ..Default::default()
525        });
526        let tokens = tok.tokenize("the cat is a friend");
527        assert_eq!(tokens.len(), 2);
528        assert_eq!(tokens[0].text, "cat");
529        assert_eq!(tokens[1].text, "friend");
530    }
531
532    #[test]
533    fn stop_words_case_insensitive() {
534        let mut tok = SemanticTokenizer::new(TokenizerConfig {
535            stop_words: vec!["the".into()],
536            ..Default::default()
537        });
538        let tokens = tok.tokenize("The THE thE dog");
539        // all "the" variants lowercased => filtered
540        assert_eq!(tokens.len(), 1);
541        assert_eq!(tokens[0].text, "dog");
542    }
543
544    #[test]
545    fn add_remove_stop_word() {
546        let mut tok = default_tokenizer();
547        assert!(!tok.is_stop_word("foo"));
548        tok.add_stop_word("foo");
549        assert!(tok.is_stop_word("foo"));
550        assert!(tok.is_stop_word("FOO")); // case insensitive lookup
551        assert!(tok.remove_stop_word("foo"));
552        assert!(!tok.is_stop_word("foo"));
553        assert!(!tok.remove_stop_word("nonexistent"));
554    }
555
556    #[test]
557    fn add_stop_word_no_duplicate() {
558        let mut tok = default_tokenizer();
559        tok.add_stop_word("hello");
560        tok.add_stop_word("hello");
561        tok.add_stop_word("HELLO"); // same after lowercase
562        assert_eq!(tok.stats().stop_words_count, 1);
563    }
564
565    // -- Min / max token length --------------------------------------------
566
567    #[test]
568    fn min_token_length() {
569        let mut tok = SemanticTokenizer::new(TokenizerConfig {
570            min_token_length: 3,
571            ..Default::default()
572        });
573        let tokens = tok.tokenize("a ab abc abcd");
574        assert_eq!(tokens.len(), 2);
575        assert_eq!(tokens[0].text, "abc");
576        assert_eq!(tokens[1].text, "abcd");
577    }
578
579    #[test]
580    fn max_token_length() {
581        let mut tok = SemanticTokenizer::new(TokenizerConfig {
582            max_token_length: 4,
583            ..Default::default()
584        });
585        let tokens = tok.tokenize("ab abcd abcdef");
586        assert_eq!(tokens.len(), 2);
587        assert_eq!(tokens[0].text, "ab");
588        assert_eq!(tokens[1].text, "abcd");
589    }
590
591    // -- Byte offset correctness -------------------------------------------
592
593    #[test]
594    fn byte_offset_whitespace() {
595        let mut tok = default_tokenizer();
596        let tokens = tok.tokenize("hello world");
597        assert_eq!(tokens[0].byte_offset, 0);
598        assert_eq!(tokens[1].byte_offset, 6);
599    }
600
601    #[test]
602    fn byte_offset_word_boundary() {
603        let mut tok = SemanticTokenizer::new(TokenizerConfig {
604            mode: TokenizerMode::WordBoundary,
605            lowercase: false,
606            ..Default::default()
607        });
608        let tokens = tok.tokenize("foo--bar");
609        assert_eq!(tokens[0].byte_offset, 0);
610        assert_eq!(tokens[0].text, "foo");
611        assert_eq!(tokens[1].byte_offset, 5);
612        assert_eq!(tokens[1].text, "bar");
613    }
614
615    #[test]
616    fn byte_offset_ngram() {
617        let mut tok = SemanticTokenizer::new(TokenizerConfig {
618            mode: TokenizerMode::NGram,
619            ngram_size: 2,
620            lowercase: false,
621            ..Default::default()
622        });
623        let tokens = tok.tokenize("abcd");
624        assert_eq!(tokens[0].byte_offset, 0); // ab
625        assert_eq!(tokens[1].byte_offset, 1); // bc
626        assert_eq!(tokens[2].byte_offset, 2); // cd
627    }
628
629    // -- Position tracking -------------------------------------------------
630
631    #[test]
632    fn position_sequential() {
633        let mut tok = default_tokenizer();
634        let tokens = tok.tokenize("a b c d");
635        for (i, t) in tokens.iter().enumerate() {
636            assert_eq!(t.position, i);
637        }
638    }
639
640    #[test]
641    fn position_with_stop_words() {
642        let mut tok = SemanticTokenizer::new(TokenizerConfig {
643            stop_words: vec!["b".into()],
644            ..Default::default()
645        });
646        let tokens = tok.tokenize("a b c");
647        // "b" filtered, positions renumbered
648        assert_eq!(tokens.len(), 2);
649        assert_eq!(tokens[0].position, 0);
650        assert_eq!(tokens[0].text, "a");
651        assert_eq!(tokens[1].position, 1);
652        assert_eq!(tokens[1].text, "c");
653    }
654
655    // -- Batch tokenization ------------------------------------------------
656
657    #[test]
658    fn tokenize_batch_basic() {
659        let mut tok = default_tokenizer();
660        let results = tok.tokenize_batch(&["hello world", "foo bar baz"]);
661        assert_eq!(results.len(), 2);
662        assert_eq!(results[0].len(), 2);
663        assert_eq!(results[1].len(), 3);
664    }
665
666    #[test]
667    fn tokenize_batch_empty_list() {
668        let mut tok = default_tokenizer();
669        let results = tok.tokenize_batch(&[]);
670        assert!(results.is_empty());
671    }
672
673    // -- token_count -------------------------------------------------------
674
675    #[test]
676    fn token_count_matches_tokenize() {
677        let mut tok = SemanticTokenizer::new(TokenizerConfig {
678            stop_words: vec!["the".into()],
679            min_token_length: 2,
680            ..Default::default()
681        });
682        let text = "the quick brown fox jumps over the lazy dog a";
683        let count = tok.token_count(text);
684        let tokens = tok.tokenize(text);
685        assert_eq!(count, tokens.len());
686    }
687
688    // -- Empty text --------------------------------------------------------
689
690    #[test]
691    fn empty_text_whitespace() {
692        let mut tok = default_tokenizer();
693        assert!(tok.tokenize("").is_empty());
694    }
695
696    #[test]
697    fn empty_text_word_boundary() {
698        let mut tok = SemanticTokenizer::new(TokenizerConfig {
699            mode: TokenizerMode::WordBoundary,
700            ..Default::default()
701        });
702        assert!(tok.tokenize("").is_empty());
703    }
704
705    #[test]
706    fn empty_text_ngram() {
707        let mut tok = SemanticTokenizer::new(TokenizerConfig {
708            mode: TokenizerMode::NGram,
709            ..Default::default()
710        });
711        assert!(tok.tokenize("").is_empty());
712    }
713
714    // -- Punctuation handling ----------------------------------------------
715
716    #[test]
717    fn punctuation_only_whitespace_mode() {
718        let mut tok = default_tokenizer();
719        let tokens = tok.tokenize("!!! ???");
720        assert_eq!(tokens.len(), 2);
721        assert_eq!(tokens[0].text, "!!!");
722        assert_eq!(tokens[1].text, "???");
723    }
724
725    #[test]
726    fn punctuation_only_word_boundary_mode() {
727        let mut tok = SemanticTokenizer::new(TokenizerConfig {
728            mode: TokenizerMode::WordBoundary,
729            ..Default::default()
730        });
731        let tokens = tok.tokenize("!!! ???");
732        assert!(tokens.is_empty());
733    }
734
735    // -- Normalize ---------------------------------------------------------
736
737    #[test]
738    fn normalize_lowercase() {
739        let tok = SemanticTokenizer::new(TokenizerConfig {
740            lowercase: true,
741            ..Default::default()
742        });
743        assert_eq!(tok.normalize("Hello WORLD"), "hello world");
744    }
745
746    #[test]
747    fn normalize_no_lowercase() {
748        let tok = SemanticTokenizer::new(TokenizerConfig {
749            lowercase: false,
750            ..Default::default()
751        });
752        assert_eq!(tok.normalize("Hello WORLD"), "Hello WORLD");
753    }
754
755    // -- Stats accuracy ----------------------------------------------------
756
757    #[test]
758    fn stats_initial() {
759        let tok = default_tokenizer();
760        let s = tok.stats();
761        assert_eq!(s.mode, TokenizerMode::Whitespace);
762        assert_eq!(s.tokens_produced, 0);
763        assert_eq!(s.texts_tokenized, 0);
764        assert_eq!(s.stop_words_count, 0);
765    }
766
767    #[test]
768    fn stats_after_tokenization() {
769        let mut tok = default_tokenizer();
770        tok.tokenize("a b c");
771        tok.tokenize("d e");
772        let s = tok.stats();
773        assert_eq!(s.texts_tokenized, 2);
774        assert_eq!(s.tokens_produced, 5);
775    }
776
777    #[test]
778    fn stats_stop_words_count() {
779        let mut tok = SemanticTokenizer::new(TokenizerConfig {
780            stop_words: vec!["x".into(), "y".into()],
781            ..Default::default()
782        });
783        assert_eq!(tok.stats().stop_words_count, 2);
784        tok.add_stop_word("z");
785        assert_eq!(tok.stats().stop_words_count, 3);
786        tok.remove_stop_word("x");
787        assert_eq!(tok.stats().stop_words_count, 2);
788    }
789
790    // -- Display for TokenizerMode -----------------------------------------
791
792    #[test]
793    fn tokenizer_mode_display() {
794        assert_eq!(format!("{}", TokenizerMode::Whitespace), "Whitespace");
795        assert_eq!(format!("{}", TokenizerMode::WordBoundary), "WordBoundary");
796        assert_eq!(format!("{}", TokenizerMode::NGram), "NGram");
797    }
798
799    // -- Unicode / multibyte -----------------------------------------------
800
801    #[test]
802    fn unicode_whitespace_mode() {
803        let mut tok = default_tokenizer();
804        let tokens = tok.tokenize("héllo wörld");
805        assert_eq!(tokens.len(), 2);
806        assert_eq!(tokens[0].text, "héllo");
807        assert_eq!(tokens[1].text, "wörld");
808    }
809
810    #[test]
811    fn byte_offset_unicode() {
812        let mut tok = SemanticTokenizer::new(TokenizerConfig {
813            lowercase: false,
814            ..Default::default()
815        });
816        // "é" is 2 bytes in UTF-8, so "héllo" = 6 bytes, then space = 1
817        let tokens = tok.tokenize("héllo w");
818        assert_eq!(tokens[0].byte_offset, 0);
819        assert_eq!(tokens[1].byte_offset, 7); // 6 bytes for héllo + 1 space
820    }
821}