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