1use std::fmt;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum TokenizerMode {
15 Whitespace,
17 WordBoundary,
19 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#[derive(Debug, Clone)]
35pub struct TokenizerConfig {
36 pub mode: TokenizerMode,
38 pub lowercase: bool,
40 pub min_token_length: usize,
42 pub max_token_length: usize,
44 pub ngram_size: usize,
46 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#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct Token {
66 pub text: String,
68 pub position: usize,
70 pub byte_offset: usize,
72}
73
74#[derive(Debug, Clone)]
76pub struct TokenizerStats {
77 pub mode: TokenizerMode,
79 pub tokens_produced: u64,
81 pub texts_tokenized: u64,
83 pub stop_words_count: usize,
85}
86
87pub struct SemanticTokenizer {
96 config: TokenizerConfig,
97 tokens_produced: u64,
98 texts_tokenized: u64,
99}
100
101impl SemanticTokenizer {
102 pub fn new(config: TokenizerConfig) -> Self {
104 Self {
105 config,
106 tokens_produced: 0,
107 texts_tokenized: 0,
108 }
109 }
110
111 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 pub fn tokenize_batch(&mut self, texts: &[&str]) -> Vec<Vec<Token>> {
122 texts.iter().map(|t| self.tokenize(t)).collect()
123 }
124
125 pub fn token_count(&self, text: &str) -> usize {
128 let raw = self.raw_segments(text);
129 self.count_filtered(&raw)
130 }
131
132 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 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 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 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 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 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 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 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#[cfg(test)]
323mod tests {
324 use super::*;
325
326 fn default_tokenizer() -> SemanticTokenizer {
327 SemanticTokenizer::new(TokenizerConfig::default())
328 }
329
330 #[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 #[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 #[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 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); assert_eq!(tokens[0].text, "ca");
493 assert_eq!(tokens[2].text, "fé");
494 }
495
496 #[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 #[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 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")); 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"); assert_eq!(tok.stats().stop_words_count, 1);
563 }
564
565 #[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 #[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); assert_eq!(tokens[1].byte_offset, 1); assert_eq!(tokens[2].byte_offset, 2); }
628
629 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 let tokens = tok.tokenize("héllo w");
818 assert_eq!(tokens[0].byte_offset, 0);
819 assert_eq!(tokens[1].byte_offset, 7); }
821}