1use std::borrow::Cow;
9use std::iter::Peekable;
10use std::sync::LazyLock;
11use std::time::Instant;
12
13use hashbrown::HashSet;
14use regex::Regex;
15use unicode_segmentation::UnicodeSegmentation;
16use whatlang::Lang;
17
18use crate::config::{ConfigNormalization, ConfigTokenization};
19use crate::query::QueryGenericLang;
20use crate::store::identifiers::{StoreTermHash, StoreTermHashed};
21
22use super::stopwords::LexerStopWord;
23
24pub struct TokenLexerBuilder;
25
26type TokensIter<'s> = Box<dyn Iterator<Item = Token<'s>> + 's>;
27
28static SPECIAL_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
29 Regex::new(concat!(
30 r"(?P<email>[\w.+-]+@[\w-]+\.[\w.-]+)",
31 r"|(?P<username>@[^\s]*\w)",
32 r"|(?P<url>\w{2,}://[^\s]*[^\s.])",
33 r"|(?P<ipv4>\d{1,3}(?:\.\d{1,3}){3})(?:[^\.\d]|$)",
34 r"|(?P<phone>\+?\d+(?:[\s\.-]?\d+){4,})",
35 r"|(?P<domain>[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})",
36 r"|(?P<id>[\w\d:_-]*[\d_][\w\d:-]*)"
37 ))
38 .unwrap()
39});
40
41pub struct Tokenizer<'s> {
42 config: ConfigTokenization,
43 text: &'s str,
44 lang: Option<Lang>,
45 regex_matches: Peekable<regex::CaptureMatches<'static, 's>>,
46 regex_cursor: usize,
47 tokens: Option<(TokensIter<'s>, usize)>,
48}
49
50impl<'s> Tokenizer<'s> {
51 fn new(text: &'s str, lang: Option<Lang>, config: &ConfigTokenization) -> Self {
52 let regex_matches = if config.detect_special_patterns {
53 SPECIAL_PATTERNS.captures_iter(text).peekable()
54 } else {
55 static NOOP_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^$").unwrap());
57 NOOP_REGEX.captures_iter(" ").peekable()
58 };
59
60 Self {
61 config: *config,
62 lang,
63 regex_matches,
64 text,
65 regex_cursor: 0,
66 tokens: None,
67 }
68 }
69}
70
71fn tokenize<'s>(text: &'s str, lang: Option<Lang>) -> Box<dyn Iterator<Item = &'s str> + 's> {
72 match lang {
73 #[cfg(feature = "tokenizer-chinese")]
74 Some(Lang::Cmn) => Box::from(
75 TOKENIZER_JIEBA
76 .cut(text, false)
77 .into_iter()
78 .map(|token| token.word),
79 ),
80 #[cfg(feature = "tokenizer-japanese")]
81 Some(Lang::Jpn) => match TOKENIZER_LINDERA.tokenize(text) {
82 Ok(tokens) => Box::from(tokens.into_iter()),
83 Err(err) => {
84 tracing::warn!("unable to tokenize japanese, falling back: {}", err);
85
86 Box::from(text.unicode_words())
87 }
88 },
89 _ => Box::from(text.unicode_words()),
90 }
91}
92
93impl<'s> Iterator for Tokenizer<'s> {
94 type Item = Token<'s>;
95
96 fn next(&mut self) -> Option<Self::Item> {
97 if let Some((words, end)) = self.tokens.as_mut() {
99 match words.next() {
100 Some(word) => return Some(word),
101 None => {
102 self.regex_cursor = *end;
103 self.tokens = None;
104 }
105 }
106 }
107
108 match self.regex_matches.peek() {
110 Some(captures) => {
111 let regex_match = captures.get_match();
112 let start = regex_match.start();
113 let end = regex_match.end();
114
115 if start > self.regex_cursor {
117 let gap = &self.text[self.regex_cursor..start];
118 let mut tokens = Box::new(tokenize(gap, self.lang).map(Token::Word));
119
120 if let Some(token) = tokens.next() {
121 self.tokens = Some((tokens, end));
122 return Some(token);
123 }
124 }
125
126 let next = if self.config.compat_split_special_patterns {
129 let regex_match = captures.get_match().as_str();
130 let words = tokenize(regex_match, self.lang);
131
132 let mut words = Box::new(words.map(|raw| Token::Special {
133 raw,
134 normalized: Cow::Borrowed(raw),
135 }));
136
137 let next = words.next().unwrap_or(Token::Special {
138 raw: regex_match,
139 normalized: Cow::Borrowed(regex_match),
140 });
141
142 self.tokens = Some((words, end));
143
144 Some(next)
145 } else {
146 Some(Token::special(captures))
147 };
148
149 self.regex_matches.next();
152 self.regex_cursor = end;
153
154 next
155 }
156 None => {
157 let gap = &self.text[self.regex_cursor..];
160 let mut tokens = Box::from(tokenize(gap, self.lang).map(Token::Word));
161
162 if let Some(token) = tokens.next() {
163 self.tokens = Some((tokens, self.text.len()));
164 return Some(token);
165 }
166
167 None
168 }
169 }
170 }
171}
172
173#[derive(Debug, PartialEq, Eq)]
174pub enum Token<'s> {
175 Word(&'s str),
177
178 Special {
181 raw: &'s str,
182 normalized: Cow<'s, str>,
183 },
184}
185
186impl<'s> Token<'s> {
187 fn special(captures: ®ex::Captures<'s>) -> Self {
188 let (raw, normalized) = if let Some(m) = captures.name("email") {
189 (m.as_str(), Cow::Borrowed(m.as_str()))
190 } else if let Some(m) = captures.name("username") {
191 (m.as_str(), Cow::Borrowed(m.as_str()))
192 } else if let Some(m) = captures.name("url") {
193 (m.as_str(), Cow::Borrowed(m.as_str()))
194 } else if let Some(m) = captures.name("ipv4") {
195 (m.as_str(), Cow::Borrowed(m.as_str()))
196 } else if let Some(m) = captures.name("phone") {
197 let raw = m.as_str();
198 let normalized: String = raw
199 .chars()
200 .filter(|c| c.is_ascii_digit() || *c == '+')
201 .collect();
202 (raw, Cow::Owned(normalized))
203 } else if let Some(m) = captures.name("domain") {
204 (m.as_str(), Cow::Borrowed(m.as_str()))
205 } else if let Some(m) = captures.name("id") {
206 (m.as_str(), Cow::Borrowed(m.as_str()))
207 } else {
208 unreachable!("One name always matches")
209 };
210
211 Self::Special { raw, normalized }
212 }
213}
214
215#[derive(PartialEq, Eq)]
216pub enum NormalizedToken {
217 Word(String),
219
220 Special(String),
223}
224
225impl std::fmt::Debug for NormalizedToken {
226 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227 match self {
228 Self::Word(str) => std::fmt::Debug::fmt(str, f),
229 Self::Special(str) => f.debug_tuple("Special").field(str).finish(),
230 }
231 }
232}
233
234impl std::ops::Deref for NormalizedToken {
235 type Target = String;
236
237 fn deref(&self) -> &Self::Target {
238 match self {
239 Self::Word(str) => str,
240 Self::Special(str) => str,
241 }
242 }
243}
244
245impl NormalizedToken {
246 pub fn is_special(&self) -> bool {
247 matches!(self, Self::Special(_))
248 }
249
250 pub fn into_inner(self) -> String {
251 match self {
252 Self::Word(str) => str,
253 Self::Special(str) => str,
254 }
255 }
256}
257
258impl From<NormalizedToken> for String {
259 #[inline]
260 fn from(value: NormalizedToken) -> Self {
261 value.into_inner()
262 }
263}
264
265pub struct TokenLexer<'a> {
266 mode: TokenLexerMode,
267 locale: Option<Lang>,
268 #[cfg(feature = "stemming")]
269 snowball_algorithm: Option<snowball::Algorithm>,
270 tokenizer: Tokenizer<'a>,
271 yields: HashSet<StoreTermHashed>,
272 config: ConfigNormalization,
273}
274
275#[derive(PartialEq)]
276pub enum TokenLexerMode {
277 NormalizeAndCleanup,
278 NormalizeOnly,
279}
280
281impl TokenLexerMode {
282 pub fn should_cleanup(&self) -> bool {
283 match self {
284 Self::NormalizeAndCleanup => true,
285 Self::NormalizeOnly => false,
286 }
287 }
288}
289
290const TEXT_LANG_TRUNCATE_OVER_CHARS: usize = 200;
291const TEXT_LANG_DETECT_PROCEED_OVER_CHARS: usize = 20;
292const TEXT_LANG_DETECT_NGRAM_UNDER_CHARS: usize = 60;
293
294#[cfg(feature = "tokenizer-chinese")]
295static TOKENIZER_JIEBA: LazyLock<jieba_rs::Jieba> = LazyLock::new(jieba_rs::Jieba::new);
296
297#[cfg(feature = "tokenizer-japanese")]
298static TOKENIZER_LINDERA: LazyLock<lindera_tokenizer::tokenizer::Tokenizer> = LazyLock::new(|| {
299 lindera_tokenizer::tokenizer::Tokenizer::from_config(
300 lindera_tokenizer::tokenizer::TokenizerConfig {
301 dictionary: lindera_dictionary::DictionaryConfig {
302 kind: Some(lindera_dictionary::DictionaryKind::UniDic),
303 path: None,
304 },
305 user_dictionary: None,
306 mode: lindera_core::mode::Mode::Normal,
307 },
308 )
309 .expect("unable to initialize japanese tokenizer")
310});
311
312impl TokenLexerBuilder {
313 pub fn from(
314 mode: TokenLexerMode,
315 lang: Option<Lang>,
316 text: &str,
317 normalization_config: ConfigNormalization,
318 tokenization_config: ConfigTokenization,
319 ) -> Result<TokenLexer<'_>, ()> {
320 let locale = match lang {
321 Some(hinted_lang) => {
323 tracing::debug!(
325 "using hinted locale: {} from lexer text: {}",
326 hinted_lang,
327 text
328 );
329
330 lang
331 }
332
333 None => match mode {
334 TokenLexerMode::NormalizeAndCleanup => {
336 let locale = Self::detect_lang(text);
337 tracing::debug!("detected locale: {:?} from lexer text: {}", locale, text);
338 locale
339 }
340
341 #[cfg(feature = "stemming")]
343 TokenLexerMode::NormalizeOnly if normalization_config.stemming_enabled => {
344 let locale = Self::detect_lang(text);
345 tracing::debug!("detected locale: {:?} from lexer text: {}", locale, text);
346 locale
347 }
348
349 TokenLexerMode::NormalizeOnly => {
351 tracing::debug!("not detecting locale from lexer text: {}", text);
352
353 None
354 }
355 },
356 };
357
358 Ok(TokenLexer::new(
360 mode,
361 text,
362 locale,
363 normalization_config,
364 tokenization_config,
365 ))
366 }
367
368 fn detect_lang(text: &str) -> Option<Lang> {
369 tracing::debug!("detecting locale from lexer text: {}", text);
370
371 if text.len() < TEXT_LANG_DETECT_PROCEED_OVER_CHARS {
374 return None;
375 }
376
377 let safe_text = if text.len() > TEXT_LANG_TRUNCATE_OVER_CHARS {
380 tracing::debug!(
381 "lexer text needs to be truncated, as it is too long ({}/{}): {}",
382 text.len(),
383 TEXT_LANG_TRUNCATE_OVER_CHARS,
384 text
385 );
386
387 let end_index = text.floor_char_boundary(TEXT_LANG_TRUNCATE_OVER_CHARS);
389 &text[..end_index]
390 } else {
391 text
392 };
393
394 tracing::debug!("will detect locale for lexer safe text: {}", safe_text);
395
396 if safe_text.len() < TEXT_LANG_DETECT_NGRAM_UNDER_CHARS {
406 tracing::debug!(
407 "lexer text is shorter than {} characters, using the slow method",
408 TEXT_LANG_DETECT_NGRAM_UNDER_CHARS
409 );
410
411 Self::detect_lang_slow(safe_text)
412 } else {
413 tracing::debug!(
414 "lexer text is equal or longer than {} characters, using the fast method",
415 TEXT_LANG_DETECT_NGRAM_UNDER_CHARS
416 );
417
418 Self::detect_lang_fast(safe_text)
419 }
420 }
421
422 fn detect_lang_slow(safe_text: &str) -> Option<Lang> {
423 let ngram_start = Instant::now();
424
425 match whatlang::detect(safe_text) {
426 Some(info) => {
427 let ngram_took = ngram_start.elapsed();
428
429 let mut locale = info.lang();
430
431 tracing::info!(
432 "[slow lexer] locale detected from text: {} ({} from {} at {}/1; {}s + {}ms)",
433 safe_text,
434 locale,
435 info.script(),
436 info.confidence(),
437 ngram_took.as_secs(),
438 ngram_took.subsec_millis()
439 );
440
441 if !info.is_reliable() {
445 tracing::debug!("[slow lexer] trying to detect locale from stopwords instead");
446
447 if let Some(alternate_locale) =
449 LexerStopWord::guess_lang(safe_text, info.script())
450 {
451 tracing::info!(
452 "[slow lexer] detected more accurate locale from stopwords: {}",
453 alternate_locale
454 );
455
456 locale = alternate_locale;
457 }
458 }
459
460 Some(locale)
461 }
462 None => {
463 tracing::info!(
464 "[slow lexer] no locale could be detected from text: {}",
465 safe_text
466 );
467
468 None
469 }
470 }
471 }
472
473 fn detect_lang_fast(safe_text: &str) -> Option<Lang> {
474 let stopwords_start = Instant::now();
475
476 match whatlang::detect_script(safe_text) {
477 Some(script) => {
478 if let Some(locale) = LexerStopWord::guess_lang(safe_text, script) {
480 let stopwords_took = stopwords_start.elapsed();
481
482 tracing::info!(
483 "[fast lexer] locale detected from text: {} ({}; {}s + {}ms)",
484 safe_text,
485 locale,
486 stopwords_took.as_secs(),
487 stopwords_took.subsec_millis()
488 );
489
490 Some(locale)
491 } else {
492 tracing::debug!(
493 "[fast lexer] trying to detect locale from fallback ngram instead"
494 );
495
496 whatlang::detect_lang(safe_text)
498 }
499 }
500 None => {
501 tracing::info!(
502 "[fast lexer] no script could be detected from text: {}",
503 safe_text
504 );
505
506 None
507 }
508 }
509 }
510}
511
512impl<'a> TokenLexer<'a> {
513 fn new(
514 mode: TokenLexerMode,
515 text: &'a str,
516 locale: Option<Lang>,
517 normalization_config: ConfigNormalization,
518 tokenization_config: ConfigTokenization,
519 ) -> TokenLexer<'a> {
520 let tokenizer = Tokenizer::new(text, locale, &tokenization_config);
522
523 #[cfg(feature = "stemming")]
525 let snowball_algorithm = match &locale {
526 Some(locale) => super::stemming::snowball_algorithm(locale),
527 None => None,
528 };
529
530 TokenLexer {
531 mode,
532 locale,
533 #[cfg(feature = "stemming")]
534 snowball_algorithm,
535 tokenizer,
536 yields: HashSet::new(),
537 config: normalization_config,
538 }
539 }
540}
541
542impl TokenLexerMode {
543 pub fn from_query_lang(lang: &Option<QueryGenericLang>) -> TokenLexerMode {
544 match lang {
545 Some(QueryGenericLang::Enabled(_)) => {
546 TokenLexerMode::NormalizeAndCleanup
548 }
549 Some(QueryGenericLang::Disabled) => {
550 TokenLexerMode::NormalizeOnly
552 }
553 None => {
554 TokenLexerMode::NormalizeAndCleanup
556 }
557 }
558 }
559}
560
561impl<'a> Iterator for TokenLexer<'a> {
562 type Item = (NormalizedToken, StoreTermHashed, usize);
563
564 fn next(&mut self) -> Option<Self::Item> {
571 'tokenize: for token in self.tokenizer.by_ref() {
572 let (word, original_len) = match token {
573 Token::Word(original_word) => {
574 let original_len = original_word.len();
575
576 #[cfg(debug_assertions)]
577 let mut current_word: String = original_word.to_owned();
578
579 let mut chars: Box<dyn Iterator<Item = char>> = Box::new(original_word.chars());
582
583 {
585 use caseless::Caseless as _;
586
587 chars = Box::new(chars.default_case_fold());
588
589 #[cfg(debug_assertions)]
590 {
591 let new_word = chars.collect();
592 tracing::trace!("Case folding: {current_word:?} -> {new_word:?}");
593 current_word = new_word;
594 chars = Box::new(current_word.chars());
595 }
596 }
597
598 if self.config.diacritic_folding_enabled {
600 use unicode_normalization::UnicodeNormalization as _;
601 use unicode_normalization::char::is_combining_mark;
602
603 chars = Box::new(chars.nfd().filter(|c| !is_combining_mark(*c)));
604
605 #[cfg(debug_assertions)]
606 {
607 let new_word = chars.collect();
608 tracing::trace!("Diacritic folding: {current_word:?} -> {new_word:?}");
609 current_word = new_word;
610 chars = Box::new(current_word.chars());
611 }
612 }
613
614 #[allow(unused_mut)]
617 let mut new_word: String = chars.collect();
618
619 #[cfg(feature = "stemming")]
621 if self.config.stemming_enabled {
622 if let Some(algo) = self.snowball_algorithm {
623 new_word = String::from(snowball::stem(algo, &new_word));
624
625 tracing::debug!(
626 "lexer stemmed word {original_word:?} into {new_word:?} using Snowball algorithm {algo:?}"
627 );
628
629 #[cfg(debug_assertions)]
630 {
631 tracing::trace!("Stemming: {current_word:?} -> {new_word:?}");
632 current_word = new_word.clone();
633 }
634 }
635 }
636
637 (NormalizedToken::Word(new_word), original_len)
638 }
639 Token::Special { normalized, .. } => {
640 let len = normalized.len();
641 (NormalizedToken::Special(normalized.into_owned()), len)
642 }
643 };
644
645 if self.mode.should_cleanup() && LexerStopWord::is(&word, self.locale) {
647 tracing::debug!("lexer did not yield word {word:?}: word is a stop-word");
648 continue 'tokenize;
649 }
650
651 let term_hash = StoreTermHash::from(&word);
655
656 if self.yields.contains(&term_hash) {
658 tracing::debug!("lexer did not yield word {word:?}: word already yielded");
659 continue 'tokenize;
660 }
661
662 tracing::debug!("lexer yielded word: {word:?}");
663
664 self.yields.insert(term_hash);
665
666 return Some((word, term_hash, original_len));
667 }
668
669 None
670 }
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676
677 const NORMALIZATION_CONFIG: ConfigNormalization = ConfigNormalization {
678 diacritic_folding_enabled: false,
679 stemming_enabled: false,
680 };
681 const TOKENIZATION_CONFIG: ConfigTokenization = ConfigTokenization {
682 detect_special_patterns: true,
683 compat_split_special_patterns: false,
684 };
685
686 #[test]
687 fn test_tokenizer() {
688 fn test(sentence: &str, expected: Vec<Token>) {
689 let tokens = Tokenizer::new(sentence, Some(Lang::Eng), &TOKENIZATION_CONFIG)
690 .take(256) .collect::<Vec<_>>();
693
694 assert_eq!(tokens, expected, "{sentence:?}");
695 }
696
697 test(
699 "Contact jane.doe@example.org, alice@example.org or bob+foo@example.org for support.",
700 vec![
701 Token::Word("Contact"),
702 Token::Special {
703 raw: "jane.doe@example.org",
704 normalized: Cow::Borrowed("jane.doe@example.org"),
705 },
706 Token::Special {
707 raw: "alice@example.org",
708 normalized: Cow::Borrowed("alice@example.org"),
709 },
710 Token::Word("or"),
711 Token::Special {
712 raw: "bob+foo@example.org",
713 normalized: Cow::Borrowed("bob+foo@example.org"),
714 },
715 Token::Word("for"),
716 Token::Word("support"),
717 ],
718 );
719
720 test(
722 "You can also call me at 555-123-4567 or +33 6 12 34 56 78 (06.12.34.56.78 / 06 12 34 56 78).",
723 vec![
724 Token::Word("You"),
725 Token::Word("can"),
726 Token::Word("also"),
727 Token::Word("call"),
728 Token::Word("me"),
729 Token::Word("at"),
730 Token::Special {
731 raw: "555-123-4567",
732 normalized: Cow::Borrowed("5551234567"),
733 },
734 Token::Word("or"),
735 Token::Special {
736 raw: "+33 6 12 34 56 78",
737 normalized: Cow::Borrowed("+33612345678"),
738 },
739 Token::Special {
740 raw: "06.12.34.56.78",
741 normalized: Cow::Borrowed("0612345678"),
742 },
743 Token::Special {
744 raw: "06 12 34 56 78",
745 normalized: Cow::Borrowed("0612345678"),
746 },
747 ],
748 );
749
750 test(
752 "My account is 6db14cb4-b82e-4e49-8016-ef76c4290a2f.",
753 vec![
754 Token::Word("My"),
755 Token::Word("account"),
756 Token::Word("is"),
757 Token::Special {
758 raw: "6db14cb4-b82e-4e49-8016-ef76c4290a2f",
759 normalized: Cow::Borrowed("6db14cb4-b82e-4e49-8016-ef76c4290a2f"),
760 },
761 ],
762 );
763
764 test(
766 "Check out b244423d417369795292e9f4530d0c0e6fa07625 and 927ff7701795282232dda41e023c7c6ba29d5a15 (927ff77).",
767 vec![
768 Token::Word("Check"),
769 Token::Word("out"),
770 Token::Special {
771 raw: "b244423d417369795292e9f4530d0c0e6fa07625",
772 normalized: Cow::Borrowed("b244423d417369795292e9f4530d0c0e6fa07625"),
773 },
774 Token::Word("and"),
775 Token::Special {
776 raw: "927ff7701795282232dda41e023c7c6ba29d5a15",
777 normalized: Cow::Borrowed("927ff7701795282232dda41e023c7c6ba29d5a15"),
778 },
779 Token::Special {
780 raw: "927ff77",
781 normalized: Cow::Borrowed("927ff77"),
782 },
783 ],
784 );
785
786 test(
788 "Have a look at https://example.org/foo?id=123.",
789 vec![
790 Token::Word("Have"),
791 Token::Word("a"),
792 Token::Word("look"),
793 Token::Word("at"),
794 Token::Special {
795 raw: "https://example.org/foo?id=123",
796 normalized: Cow::Borrowed("https://example.org/foo?id=123"),
797 },
798 ],
799 );
800
801 test(
803 "My domain name is example.org.",
804 vec![
805 Token::Word("My"),
806 Token::Word("domain"),
807 Token::Word("name"),
808 Token::Word("is"),
809 Token::Special {
810 raw: "example.org",
811 normalized: Cow::Borrowed("example.org"),
812 },
813 ],
814 );
815 test(
816 "I don’t put punctuation correctly .See?",
817 vec![
818 Token::Word("I"),
819 Token::Word("don’t"),
820 Token::Word("put"),
821 Token::Word("punctuation"),
822 Token::Word("correctly"),
823 Token::Word("See"),
824 ],
825 );
826
827 test(
829 "Try to ping 192.168.1.0, 0.0.0.0, 2606:4700::6812:1c68, or ::1.",
830 vec![
831 Token::Word("Try"),
832 Token::Word("to"),
833 Token::Word("ping"),
834 Token::Special {
835 raw: "192.168.1.0",
836 normalized: Cow::Borrowed("192.168.1.0"),
837 },
838 Token::Special {
839 raw: "0.0.0.0",
840 normalized: Cow::Borrowed("0.0.0.0"),
841 },
842 Token::Special {
843 raw: "2606:4700::6812:1c68",
844 normalized: Cow::Borrowed("2606:4700::6812:1c68"),
845 },
846 Token::Word("or"),
847 Token::Special {
848 raw: "::1",
849 normalized: Cow::Borrowed("::1"),
850 },
851 ],
852 );
853
854 test(
856 "Contact @alice.",
857 vec![
858 Token::Word("Contact"),
859 Token::Special {
860 raw: "@alice",
861 normalized: Cow::Borrowed("@alice"),
862 },
863 ],
864 );
865
866 test(
868 "It’s tested in test_tokenizer.",
869 vec![
870 Token::Word("It’s"),
871 Token::Word("tested"),
872 Token::Word("in"),
873 Token::Special {
874 raw: "test_tokenizer",
875 normalized: Cow::Borrowed("test_tokenizer"),
876 },
877 ],
878 );
879 }
880
881 #[test]
882 fn it_cleans_token_english() {
883 let token_cleaner = TokenLexerBuilder::from(
884 TokenLexerMode::NormalizeAndCleanup,
885 None,
886 "The quick brown fox jumps over the lazy dog!",
887 NORMALIZATION_CONFIG,
888 TOKENIZATION_CONFIG,
889 )
890 .unwrap();
891
892 assert_eq!(token_cleaner.locale, Some(Lang::Eng));
893
894 let mut tokens = token_cleaner.map(|(token, _, _)| token.into_inner());
895
896 assert_eq!(tokens.next(), Some("quick".to_owned()));
897 assert_eq!(tokens.next(), Some("brown".to_owned()));
898 assert_eq!(tokens.next(), Some("fox".to_owned()));
899 assert_eq!(tokens.next(), Some("jumps".to_owned()));
900 assert_eq!(tokens.next(), Some("lazy".to_owned()));
901 assert_eq!(tokens.next(), Some("dog".to_owned()));
902 assert_eq!(tokens.next(), None);
903 }
904
905 #[test]
906 fn it_cleans_token_french() {
907 let token_cleaner = TokenLexerBuilder::from(
908 TokenLexerMode::NormalizeAndCleanup,
909 None,
910 "Le vif renard brun saute par dessus le chien paresseux.",
911 NORMALIZATION_CONFIG,
912 TOKENIZATION_CONFIG,
913 )
914 .unwrap();
915
916 assert_eq!(token_cleaner.locale, Some(Lang::Fra));
917
918 let mut tokens = token_cleaner.map(|(token, _, _)| token.into_inner());
919
920 assert_eq!(tokens.next(), Some("renard".to_owned()));
921 assert_eq!(tokens.next(), Some("brun".to_owned()));
922 assert_eq!(tokens.next(), Some("saute".to_owned()));
923 assert_eq!(tokens.next(), Some("chien".to_owned()));
924 assert_eq!(tokens.next(), Some("paresseux".to_owned()));
925 assert_eq!(tokens.next(), None);
926 }
927
928 #[cfg(feature = "tokenizer-chinese")]
929 #[test]
930 fn it_cleans_token_chinese_jieba() {
931 let token_cleaner = TokenLexerBuilder::from(
932 TokenLexerMode::NormalizeAndCleanup,
933 None,
934 "我们中出了一个叛徒",
935 NORMALIZATION_CONFIG,
936 TOKENIZATION_CONFIG,
937 )
938 .unwrap();
939
940 assert_eq!(token_cleaner.locale, Some(Lang::Cmn));
941
942 let mut tokens = token_cleaner.map(|(token, _, _)| token.into_inner());
943
944 assert_eq!(tokens.next(), Some("出".to_owned()));
945 assert_eq!(tokens.next(), Some("一个".to_owned()));
946 assert_eq!(tokens.next(), Some("叛徒".to_owned()));
947 assert_eq!(tokens.next(), None);
948 }
949
950 #[cfg(not(feature = "tokenizer-chinese"))]
951 #[test]
952 fn it_cleans_token_chinese_naive() {
953 let token_cleaner = TokenLexerBuilder::from(
954 TokenLexerMode::NormalizeAndCleanup,
955 None,
956 "快狐跨懒狗快狐跨懒狗",
957 NORMALIZATION_CONFIG,
958 TOKENIZATION_CONFIG,
959 )
960 .unwrap();
961
962 assert_eq!(token_cleaner.locale, Some(Lang::Cmn));
963
964 let mut tokens = token_cleaner.map(|(token, _, _)| token.into_inner());
965
966 assert_eq!(tokens.next(), Some("快".to_owned()));
967 assert_eq!(tokens.next(), Some("狐".to_owned()));
968 assert_eq!(tokens.next(), Some("跨".to_owned()));
969 assert_eq!(tokens.next(), Some("懒".to_owned()));
970 assert_eq!(tokens.next(), Some("狗".to_owned()));
971 assert_eq!(tokens.next(), None);
972 }
973
974 #[cfg(feature = "tokenizer-japanese")]
975 #[test]
976 fn it_cleans_token_japanese_lindera_product() {
977 let mut token_cleaner = TokenLexerBuilder::from(
978 TokenLexerMode::NormalizeAndCleanup,
979 None,
980 "関西国際空港限定トートバッグ",
981 NORMALIZATION_CONFIG,
982 TOKENIZATION_CONFIG,
983 )
984 .unwrap();
985
986 assert_eq!(token_cleaner.locale, Some(Lang::Jpn));
987
988 let mut tokens = token_cleaner.map(|(token, _, _)| token.into_inner());
989
990 assert_eq!(tokens.next(), Some("関西".to_owned()));
991 assert_eq!(tokens.next(), Some("国際".to_owned()));
992 assert_eq!(tokens.next(), Some("空港".to_owned()));
993 assert_eq!(tokens.next(), Some("限定".to_owned()));
994 assert_eq!(tokens.next(), Some("トート".to_owned()));
995 assert_eq!(tokens.next(), Some("バッグ".to_owned()));
996 assert_eq!(tokens.next(), None);
997 }
998
999 #[cfg(feature = "tokenizer-japanese")]
1000 #[test]
1001 fn it_cleans_token_japanese_lindera_food() {
1002 let token_cleaner = TokenLexerBuilder::from(
1003 TokenLexerMode::NormalizeAndCleanup,
1004 None,
1005 "𠮷野家",
1006 NORMALIZATION_CONFIG,
1007 TOKENIZATION_CONFIG,
1008 )
1009 .unwrap();
1010
1011 assert_eq!(token_cleaner.locale, None);
1012
1013 let token_cleaner = TokenLexerBuilder::from(
1014 TokenLexerMode::NormalizeAndCleanup,
1015 None,
1016 "ヱビスビール",
1017 NORMALIZATION_CONFIG,
1018 TOKENIZATION_CONFIG,
1019 )
1020 .unwrap();
1021
1022 assert_eq!(token_cleaner.locale, None);
1023 }
1024
1025 #[cfg(feature = "tokenizer-japanese")]
1026 #[test]
1027 fn it_cleans_token_japanese_lindera_sentence() {
1028 let mut token_cleaner = TokenLexerBuilder::from(
1029 TokenLexerMode::NormalizeAndCleanup,
1030 None,
1031 "𠮷野家でヱビスビールを飲んだ",
1032 NORMALIZATION_CONFIG,
1033 TOKENIZATION_CONFIG,
1034 )
1035 .unwrap();
1036
1037 assert_eq!(token_cleaner.locale, Some(Lang::Jpn));
1038
1039 let mut tokens = token_cleaner.map(|(token, _, _)| token.into_inner());
1040
1041 assert_eq!(tokens.next(), Some("𠮷".to_owned()));
1042 assert_eq!(tokens.next(), Some("野家".to_owned()));
1043 assert_eq!(tokens.next(), Some("ヱビス".to_owned()));
1044 assert_eq!(tokens.next(), Some("ビール".to_owned()));
1045 assert_eq!(tokens.next(), Some("飲ん".to_owned()));
1046 assert_eq!(tokens.next(), None);
1047 }
1048
1049 #[test]
1050 fn it_cleans_token_emojis() {
1051 let mut token_cleaner = TokenLexerBuilder::from(
1052 TokenLexerMode::NormalizeAndCleanup,
1053 None,
1054 "🚀 🙋♂️🙋♂️🙋♂️",
1055 NORMALIZATION_CONFIG,
1056 TOKENIZATION_CONFIG,
1057 )
1058 .unwrap();
1059
1060 assert_eq!(token_cleaner.locale, None);
1061
1062 assert_eq!(token_cleaner.next(), None);
1063 }
1064
1065 #[test]
1066 fn it_cleans_token_lang_hinted() {
1067 let token_cleaner_right = TokenLexerBuilder::from(
1068 TokenLexerMode::NormalizeAndCleanup,
1069 Some(Lang::Eng),
1070 "This will be cleaned properly, as English was hinted rightfully so.",
1071 NORMALIZATION_CONFIG,
1072 TOKENIZATION_CONFIG,
1073 )
1074 .unwrap();
1075 let token_cleaner_wrong = TokenLexerBuilder::from(
1076 TokenLexerMode::NormalizeAndCleanup,
1077 Some(Lang::Fra),
1078 "This will not be cleaned properly, as French was hinted but this is English.",
1079 NORMALIZATION_CONFIG,
1080 TOKENIZATION_CONFIG,
1081 )
1082 .unwrap();
1083
1084 assert_eq!(token_cleaner_right.locale, Some(Lang::Eng));
1085 assert_eq!(token_cleaner_wrong.locale, Some(Lang::Fra));
1086
1087 let mut tokens_right = token_cleaner_right.map(|(token, _, _)| token.into_inner());
1088 let mut tokens_wrong = token_cleaner_wrong.map(|(token, _, _)| token.into_inner());
1089
1090 assert_eq!(tokens_right.next(), Some("cleaned".to_owned()));
1091 assert_eq!(tokens_wrong.next(), Some("this".to_owned()));
1092 }
1093
1094 #[test]
1095 fn it_detects_lang_english_regular() {
1096 assert_eq!(
1097 TokenLexerBuilder::detect_lang("The quick brown fox jumps over the lazy dog!"),
1098 Some(Lang::Eng)
1099 );
1100 }
1101
1102 #[test]
1103 fn it_detects_lang_english_long() {
1104 assert_eq!(
1105 TokenLexerBuilder::detect_lang(
1106 r#"Running an electrical current through water splits it into oxygen and hydrogen,
1107 the latter of which can be used as a reliable, zero-emission fuel source. In the past,
1108 the process of purifying water beforehand was too energy intensive for this process to
1109 be useful — but now scientists have figured out how to skip the process altogether and
1110 convert seawater into usable hydrogen"#
1111 ),
1112 Some(Lang::Eng)
1113 );
1114 }
1115
1116 #[test]
1117 fn it_doesnt_detect_lang_english_tiny() {
1118 assert_eq!(TokenLexerBuilder::detect_lang("The quick"), None);
1119 }
1120}
1121
1122#[cfg(all(feature = "benchmark", test))]
1123mod benches {
1124 extern crate test;
1125
1126 use super::*;
1127 use test::Bencher;
1128
1129 #[bench]
1130 fn bench_normalize_token_french_build(b: &mut Bencher) {
1131 b.iter(|| {
1132 TokenLexerBuilder::from(
1133 TokenLexerMode::NormalizeOnly,
1134 "Le vif renard brun saute par dessus le chien paresseux.",
1135 NORMALIZATION_CONFIG,
1136 TOKENIZATION_CONFIG,
1137 )
1138 });
1139 }
1140
1141 #[bench]
1142 fn bench_normalize_token_french_exhaust(b: &mut Bencher) {
1143 b.iter(|| {
1144 let token_cleaner = TokenLexerBuilder::from(
1145 TokenLexerMode::NormalizeOnly,
1146 "Le vif renard brun saute par dessus le chien paresseux.",
1147 NORMALIZATION_CONFIG,
1148 TOKENIZATION_CONFIG,
1149 )
1150 .unwrap();
1151
1152 token_cleaner.map(|value| value.1).collect::<Vec<u32>>()
1153 });
1154 }
1155
1156 #[bench]
1157 fn bench_clean_token_english_regular_build(b: &mut Bencher) {
1158 b.iter(|| {
1159 TokenLexerBuilder::from(
1160 TokenLexerMode::NormalizeAndCleanup,
1161 None,
1162 "The quick brown fox jumps over the lazy dog!",
1163 NORMALIZATION_CONFIG,
1164 TOKENIZATION_CONFIG,
1165 )
1166 });
1167 }
1168
1169 #[bench]
1170 fn bench_clean_token_english_regular_exhaust(b: &mut Bencher) {
1171 b.iter(|| {
1172 let token_cleaner = TokenLexerBuilder::from(
1173 TokenLexerMode::NormalizeAndCleanup,
1174 None,
1175 "The quick brown fox jumps over the lazy dog!",
1176 NORMALIZATION_CONFIG,
1177 TOKENIZATION_CONFIG,
1178 )
1179 .unwrap();
1180
1181 token_cleaner.map(|value| value.1).collect::<Vec<u32>>()
1182 });
1183 }
1184
1185 #[bench]
1186 fn bench_clean_token_english_long_exhaust(b: &mut Bencher) {
1187 b.iter(|| {
1188 let token_cleaner = TokenLexerBuilder::from(
1189 TokenLexerMode::NormalizeAndCleanup,
1190 None,
1191 r#"Running an electrical current through water splits it into oxygen and hydrogen,
1192 the latter of which can be used as a reliable, zero-emission fuel source. In the
1193 past, the process of purifying water beforehand was too energy intensive for this
1194 process to be useful — but now scientists have figured out how to skip the process
1195 altogether and convert seawater into usable hydrogen"#,
1196 NORMALIZATION_CONFIG,
1197 TOKENIZATION_CONFIG,
1198 )
1199 .unwrap();
1200
1201 token_cleaner.map(|value| value.1).collect::<Vec<u32>>()
1202 });
1203 }
1204
1205 #[bench]
1206 fn bench_clean_token_english_hinted_build(b: &mut Bencher) {
1207 b.iter(|| {
1208 TokenLexerBuilder::from(
1209 TokenLexerMode::NormalizeAndCleanup(Some(Lang::Eng)),
1210 "The quick brown fox jumps over the lazy dog!",
1211 NORMALIZATION_CONFIG,
1212 TOKENIZATION_CONFIG,
1213 )
1214 });
1215 }
1216
1217 #[bench]
1218 fn bench_clean_token_english_hinted_exhaust(b: &mut Bencher) {
1219 b.iter(|| {
1220 let token_cleaner = TokenLexerBuilder::from(
1221 TokenLexerMode::NormalizeAndCleanup(Some(Lang::Eng)),
1222 "The quick brown fox jumps over the lazy dog!",
1223 NORMALIZATION_CONFIG,
1224 TOKENIZATION_CONFIG,
1225 )
1226 .unwrap();
1227
1228 token_cleaner.map(|value| value.1).collect::<Vec<u32>>()
1229 });
1230 }
1231
1232 #[bench]
1233 fn bench_clean_token_chinese_build(b: &mut Bencher) {
1234 b.iter(|| {
1235 TokenLexerBuilder::from(
1236 TokenLexerMode::NormalizeAndCleanup,
1237 None,
1238 "我们中出了一个叛徒",
1239 NORMALIZATION_CONFIG,
1240 TOKENIZATION_CONFIG,
1241 )
1242 });
1243 }
1244
1245 #[bench]
1246 fn bench_clean_token_chinese_exhaust(b: &mut Bencher) {
1247 b.iter(|| {
1248 let token_cleaner = TokenLexerBuilder::from(
1249 TokenLexerMode::NormalizeAndCleanup,
1250 None,
1251 "我们中出了一个叛徒",
1252 NORMALIZATION_CONFIG,
1253 TOKENIZATION_CONFIG,
1254 )
1255 .unwrap();
1256
1257 token_cleaner.map(|value| value.1).collect::<Vec<u32>>()
1258 });
1259 }
1260
1261 #[bench]
1262 fn bench_clean_token_japanese_build(b: &mut Bencher) {
1263 b.iter(|| {
1264 TokenLexerBuilder::from(
1265 TokenLexerMode::NormalizeAndCleanup,
1266 None,
1267 "関西国際空港限定トートバッグ",
1268 NORMALIZATION_CONFIG,
1269 TOKENIZATION_CONFIG,
1270 )
1271 });
1272 }
1273
1274 #[bench]
1275 fn bench_clean_token_japanese_exhaust(b: &mut Bencher) {
1276 b.iter(|| {
1277 let token_cleaner = TokenLexerBuilder::from(
1278 TokenLexerMode::NormalizeAndCleanup,
1279 None,
1280 "関西国際空港限定トートバッグ",
1281 NORMALIZATION_CONFIG,
1282 TOKENIZATION_CONFIG,
1283 )
1284 .unwrap();
1285
1286 token_cleaner.map(|value| value.1).collect::<Vec<u32>>()
1287 });
1288 }
1289
1290 #[bench]
1291 fn bench_detect_lang_english_short(b: &mut Bencher) {
1292 b.iter(|| TokenLexerBuilder::detect_lang("The quick brown fox."));
1293 }
1294
1295 #[bench]
1296 fn bench_detect_lang_english_regular(b: &mut Bencher) {
1297 b.iter(|| TokenLexerBuilder::detect_lang("The quick brown fox jumps over the lazy dog!"));
1298 }
1299
1300 #[bench]
1301 fn bench_detect_lang_english_long(b: &mut Bencher) {
1302 b.iter(|| {
1303 TokenLexerBuilder::detect_lang(
1304 r#"Running an electrical current through water splits it into oxygen and hydrogen,
1305 the latter of which can be used as a reliable, zero-emission fuel source. In the past,
1306 the process of purifying water beforehand was too energy intensive for this process to
1307 be useful — but now scientists have figured out how to skip the process altogether and
1308 convert seawater into usable hydrogen"#,
1309 )
1310 });
1311 }
1312
1313 #[bench]
1314 fn bench_dont_detect_lang_english_tiny(b: &mut Bencher) {
1315 b.iter(|| TokenLexerBuilder::detect_lang("The quick"));
1316 }
1317}