Skip to main content

sonic/lexer/
token.rs

1// Sonic
2//
3// Fast, lightweight and schema-less search backend
4// Copyright: 2019, Valerian Saliou <valerian@valeriansaliou.name>
5// License: Mozilla Public License v2.0 (MPL v2.0)
6
7use hashbrown::HashSet;
8use std::time::Instant;
9use unicode_segmentation::{UnicodeSegmentation, UnicodeWords};
10use whatlang::{
11    Lang, detect as lang_detect_all, detect_lang as lang_detect, detect_script as script_detect,
12};
13
14#[cfg(feature = "tokenizer-chinese")]
15use std::vec::IntoIter;
16
17use super::stopwords::LexerStopWord;
18use crate::config::ConfigNormalization;
19use crate::query::QueryGenericLang;
20use crate::store::identifiers::{StoreTermHash, StoreTermHashed};
21
22pub struct TokenLexerBuilder;
23
24pub struct TokenLexer<'a> {
25    mode: TokenLexerMode,
26    locale: Option<Lang>,
27    #[cfg(feature = "stemming")]
28    snowball_algorithm: Option<snowball::Algorithm>,
29    words: TokenLexerWords<'a>,
30    yields: HashSet<StoreTermHashed>,
31    config: ConfigNormalization,
32}
33
34#[derive(PartialEq)]
35pub enum TokenLexerMode {
36    NormalizeAndCleanup,
37    NormalizeOnly,
38}
39
40enum TokenLexerWords<'a> {
41    UAX29(UnicodeWords<'a>),
42
43    #[cfg(feature = "tokenizer-chinese")]
44    JieBa(IntoIter<&'a str>),
45
46    #[cfg(feature = "tokenizer-japanese")]
47    Lindera(IntoIter<lindera_tokenizer::token::Token<'a>>),
48}
49
50const TEXT_LANG_TRUNCATE_OVER_CHARS: usize = 200;
51const TEXT_LANG_DETECT_PROCEED_OVER_CHARS: usize = 20;
52const TEXT_LANG_DETECT_NGRAM_UNDER_CHARS: usize = 60;
53
54#[cfg(feature = "tokenizer-chinese")]
55lazy_static! {
56    static ref TOKENIZER_JIEBA: jieba_rs::Jieba = jieba_rs::Jieba::new();
57}
58
59#[cfg(feature = "tokenizer-japanese")]
60lazy_static! {
61    static ref TOKENIZER_LINDERA: lindera_tokenizer::tokenizer::Tokenizer =
62        lindera_tokenizer::tokenizer::Tokenizer::from_config(
63            lindera_tokenizer::tokenizer::TokenizerConfig {
64                dictionary: lindera_dictionary::DictionaryConfig {
65                    kind: Some(lindera_dictionary::DictionaryKind::UniDic),
66                    path: None
67                },
68                user_dictionary: None,
69                mode: lindera_core::mode::Mode::Normal,
70            }
71        )
72        .expect("unable to initialize japanese tokenizer");
73}
74
75impl TokenLexerBuilder {
76    pub fn from(
77        mode: TokenLexerMode,
78        lang: Option<Lang>,
79        text: &str,
80        config: ConfigNormalization,
81    ) -> Result<TokenLexer<'_>, ()> {
82        let locale = match lang {
83            // If user provided a language, use it.
84            Some(hinted_lang) => {
85                // Use hinted language (current lexer mode asks for a cleanup)
86                tracing::debug!(
87                    "using hinted locale: {} from lexer text: {}",
88                    hinted_lang,
89                    text
90                );
91
92                lang
93            }
94
95            None => match mode {
96                // If user asked to cleanup, detect the language.
97                TokenLexerMode::NormalizeAndCleanup => {
98                    let locale = Self::detect_lang(text);
99                    tracing::debug!("detected locale: {:?} from lexer text: {}", locale, text);
100                    locale
101                }
102
103                // If user asked not to cleanup but stemming is enabled, detect the language.
104                #[cfg(feature = "stemming")]
105                TokenLexerMode::NormalizeOnly if config.stemming_enabled => {
106                    let locale = Self::detect_lang(text);
107                    tracing::debug!("detected locale: {:?} from lexer text: {}", locale, text);
108                    locale
109                }
110
111                // Otherwise, don’t detect the language.
112                TokenLexerMode::NormalizeOnly => {
113                    tracing::debug!("not detecting locale from lexer text: {}", text);
114
115                    None
116                }
117            },
118        };
119
120        // Build final token builder iterator
121        Ok(TokenLexer::new(mode, text, locale, config))
122    }
123
124    fn detect_lang(text: &str) -> Option<Lang> {
125        tracing::debug!("detecting locale from lexer text: {}", text);
126
127        // Detect only if text is long-enough to allow the text locale detection system to \
128        //   function properly
129        if text.len() < TEXT_LANG_DETECT_PROCEED_OVER_CHARS {
130            return None;
131        }
132
133        // Truncate text if necessary, as to avoid the ngram or stopwords detector to be \
134        //   ran on more words than those that are enough to reliably detect a locale.
135        let safe_text = if text.len() > TEXT_LANG_TRUNCATE_OVER_CHARS {
136            tracing::debug!(
137                "lexer text needs to be truncated, as it is too long ({}/{}): {}",
138                text.len(),
139                TEXT_LANG_TRUNCATE_OVER_CHARS,
140                text
141            );
142
143            // Perform an UTF-8 aware truncation
144            // Notice: then 'len()' check above was not UTF-8 aware, but is better than \
145            //   nothing as it avoids entering the below iterator for small strings.
146            // Notice: we fallback on text if the result is 'None'; as if it is 'None' there \
147            //   was less characters than the truncate limit in the UTF-8 parsed text. With \
148            //   this unwrap-way, we avoid doing a 'text.chars().count()' every time, which is \
149            //   a O(N) operation, and rather guard this block with a 'text.len()' which is \
150            //   a O(1) operation but which is not 100% reliable when approaching the truncate \
151            //   limit. This is a trade-off, which saves quite a lot CPU cycles at scale.
152            text.char_indices()
153                .nth(TEXT_LANG_TRUNCATE_OVER_CHARS)
154                .map(|(end_index, _)| &text[0..end_index])
155                .unwrap_or(text)
156        } else {
157            text
158        };
159
160        tracing::debug!("will detect locale for lexer safe text: {}", safe_text);
161
162        // Attempt to detect the locale from text using an hybrid method that maximizes both \
163        //   accuracy and performance.
164        // Notice: as the 'ngram' method is almost 10x slower than the 'stopwords' method, we \
165        //   prefer using the 'stopwords' method on long texts where we can be sure to see quite \
166        //   a lot of stopwords which will produce a reliable result. However, for shorter texts \
167        //   there are not enough north none stopwords, thus we use the slower 'ngram' method as \
168        //   an attempt to extract the locale using trigrams. Still, if either of these methods \
169        //   fails at detecting a locale it will try using the other method in fallback as to \
170        //   produce the most reliable result while minimizing CPU cycles.
171        if safe_text.len() < TEXT_LANG_DETECT_NGRAM_UNDER_CHARS {
172            tracing::debug!(
173                "lexer text is shorter than {} characters, using the slow method",
174                TEXT_LANG_DETECT_NGRAM_UNDER_CHARS
175            );
176
177            Self::detect_lang_slow(safe_text)
178        } else {
179            tracing::debug!(
180                "lexer text is equal or longer than {} characters, using the fast method",
181                TEXT_LANG_DETECT_NGRAM_UNDER_CHARS
182            );
183
184            Self::detect_lang_fast(safe_text)
185        }
186    }
187
188    fn detect_lang_slow(safe_text: &str) -> Option<Lang> {
189        let ngram_start = Instant::now();
190
191        match lang_detect_all(safe_text) {
192            Some(detector) => {
193                let ngram_took = ngram_start.elapsed();
194
195                let mut locale = detector.lang();
196
197                tracing::info!(
198                    "[slow lexer] locale detected from text: {} ({} from {} at {}/1; {}s + {}ms)",
199                    safe_text,
200                    locale,
201                    detector.script(),
202                    detector.confidence(),
203                    ngram_took.as_secs(),
204                    ngram_took.subsec_millis()
205                );
206
207                // Confidence is low, try to detect locale from stop-words.
208                // Notice: this is a fallback but should not be too reliable for short \
209                //   texts.
210                if !detector.is_reliable() {
211                    tracing::debug!("[slow lexer] trying to detect locale from stopwords instead");
212
213                    // Better alternate locale found?
214                    if let Some(alternate_locale) =
215                        LexerStopWord::guess_lang(safe_text, detector.script())
216                    {
217                        tracing::info!(
218                            "[slow lexer] detected more accurate locale from stopwords: {}",
219                            alternate_locale
220                        );
221
222                        locale = alternate_locale;
223                    }
224                }
225
226                Some(locale)
227            }
228            None => {
229                tracing::info!(
230                    "[slow lexer] no locale could be detected from text: {}",
231                    safe_text
232                );
233
234                None
235            }
236        }
237    }
238
239    fn detect_lang_fast(safe_text: &str) -> Option<Lang> {
240        let stopwords_start = Instant::now();
241
242        match script_detect(safe_text) {
243            Some(script) => {
244                // Locale found?
245                if let Some(locale) = LexerStopWord::guess_lang(safe_text, script) {
246                    let stopwords_took = stopwords_start.elapsed();
247
248                    tracing::info!(
249                        "[fast lexer] locale detected from text: {} ({}; {}s + {}ms)",
250                        safe_text,
251                        locale,
252                        stopwords_took.as_secs(),
253                        stopwords_took.subsec_millis()
254                    );
255
256                    Some(locale)
257                } else {
258                    tracing::debug!(
259                        "[fast lexer] trying to detect locale from fallback ngram instead"
260                    );
261
262                    // No locale found, fallback on slow ngram.
263                    lang_detect(safe_text)
264                }
265            }
266            None => {
267                tracing::info!(
268                    "[fast lexer] no script could be detected from text: {}",
269                    safe_text
270                );
271
272                None
273            }
274        }
275    }
276}
277
278impl<'a> TokenLexer<'a> {
279    fn new(
280        mode: TokenLexerMode,
281        text: &'a str,
282        locale: Option<Lang>,
283        config: ConfigNormalization,
284    ) -> TokenLexer<'a> {
285        // Tokenize words (depending on the locale)
286        let words = match locale {
287            #[cfg(feature = "tokenizer-chinese")]
288            Some(Lang::Cmn) => TokenLexerWords::JieBa(TOKENIZER_JIEBA.cut(text, false).into_iter()),
289            #[cfg(feature = "tokenizer-japanese")]
290            Some(Lang::Jpn) => match TOKENIZER_LINDERA.tokenize(text) {
291                Ok(tokens) => TokenLexerWords::Lindera(tokens.into_iter()),
292                Err(err) => {
293                    tracing::warn!("unable to tokenize japanese, falling back: {}", err);
294
295                    TokenLexerWords::UAX29(text.unicode_words())
296                }
297            },
298            _ => TokenLexerWords::UAX29(text.unicode_words()),
299        };
300
301        // Identify Snowball algorithm now to avoid doing it for every token.
302        #[cfg(feature = "stemming")]
303        let snowball_algorithm = match &locale {
304            Some(locale) => super::stemming::snowball_algorithm(locale),
305            None => None,
306        };
307
308        TokenLexer {
309            mode,
310            locale,
311            #[cfg(feature = "stemming")]
312            snowball_algorithm,
313            words,
314            yields: HashSet::new(),
315            config,
316        }
317    }
318}
319
320impl TokenLexerMode {
321    pub fn from_query_lang(lang: &Option<QueryGenericLang>) -> TokenLexerMode {
322        match lang {
323            Some(QueryGenericLang::Enabled(_)) => {
324                // Cleanup with provided language
325                TokenLexerMode::NormalizeAndCleanup
326            }
327            Some(QueryGenericLang::Disabled) => {
328                // Normalize only (language purposefully set to 'none')
329                TokenLexerMode::NormalizeOnly
330            }
331            None => {
332                // Auto-detect language and cleanup (this is the default behavior)
333                TokenLexerMode::NormalizeAndCleanup
334            }
335        }
336    }
337}
338
339impl<'a> Iterator for TokenLexer<'a> {
340    type Item = (String, StoreTermHashed, usize);
341
342    // Guarantees provided by the lexer on the output: \
343    //   - Text is split per-word in a script-aware way \
344    //   - Words are normalized (i.e. case is folded (≈ lower-cased), \
345    //     diacritics are optionally folded, word is opionally stemmed) \
346    //   - Gibberish words are removed (ie. words that may just be junk) \
347    //   - Stop-words are removed
348    fn next(&mut self) -> Option<Self::Item> {
349        for original_word in &mut self.words {
350            let original_len = original_word.len();
351
352            let word = {
353                #[cfg(debug_assertions)]
354                let mut current_word: String = original_word.to_owned();
355
356                // NOTE: We use an iterator to avoid unnecessary `String`
357                //   allocations.
358                let mut chars: Box<dyn Iterator<Item = char>> = Box::new(original_word.chars());
359
360                // Case folding
361                {
362                    use caseless::Caseless as _;
363
364                    chars = Box::new(chars.default_case_fold());
365
366                    #[cfg(debug_assertions)]
367                    {
368                        let new_word = chars.collect();
369                        tracing::trace!("Case folding: {current_word:?} -> {new_word:?}");
370                        current_word = new_word;
371                        chars = Box::new(current_word.chars());
372                    }
373                }
374
375                // Diacritic folding
376                if self.config.diacritic_folding_enabled {
377                    use unicode_normalization::UnicodeNormalization as _;
378                    use unicode_normalization::char::is_combining_mark;
379
380                    chars = Box::new(chars.nfd().filter(|c| !is_combining_mark(*c)));
381
382                    #[cfg(debug_assertions)]
383                    {
384                        let new_word = chars.collect();
385                        tracing::trace!("Diacritic folding: {current_word:?} -> {new_word:?}");
386                        current_word = new_word;
387                        chars = Box::new(current_word.chars());
388                    }
389                }
390
391                // NOTE: We need to collect here as stemming algorithms need to
392                //   lookup whole words.
393                #[allow(unused_mut)]
394                let mut new_word: String = chars.collect();
395
396                // Stemming
397                #[cfg(feature = "stemming")]
398                if self.config.stemming_enabled {
399                    if let Some(algo) = self.snowball_algorithm {
400                        new_word = String::from(snowball::stem(algo, &new_word));
401
402                        tracing::debug!(
403                            "lexer stemmed word {original_word:?} into {new_word:?} \
404                            using Snowball algorithm {algo:?}"
405                        );
406
407                        #[cfg(debug_assertions)]
408                        {
409                            tracing::trace!("Stemming: {current_word:?} -> {new_word:?}");
410                            current_word = new_word.clone();
411                        }
412                    }
413                }
414
415                new_word
416            };
417
418            // Check if normalized word is a stop-word? (if should normalize and cleanup)
419            if self.mode == TokenLexerMode::NormalizeOnly || !LexerStopWord::is(&word, self.locale)
420            {
421                // Hash the term (this is used by all iterator consumers, as well as internally \
422                //   in the iterator to keep track of already-yielded words in a space-optimized \
423                //   manner, ie. by using 32-bit unsigned integer hashes)
424                let term_hash = StoreTermHash::from(&word);
425
426                // Check if word was not already yielded? (we return unique words)
427                if !self.yields.contains(&term_hash) {
428                    tracing::debug!("lexer yielded word: {}", word);
429
430                    self.yields.insert(term_hash);
431
432                    return Some((word, term_hash, original_len));
433                } else {
434                    tracing::debug!(
435                        "lexer did not yield word: {} because: word already yielded",
436                        word
437                    );
438                }
439            } else {
440                tracing::debug!(
441                    "lexer did not yield word: {} because: word is a stop-word",
442                    word
443                );
444            }
445        }
446
447        None
448    }
449}
450
451impl<'a> Iterator for TokenLexerWords<'a> {
452    type Item = &'a str;
453
454    fn next(&mut self) -> Option<Self::Item> {
455        match self {
456            TokenLexerWords::UAX29(token) => token.next(),
457
458            #[cfg(feature = "tokenizer-chinese")]
459            TokenLexerWords::JieBa(token) => token.next(),
460
461            #[cfg(feature = "tokenizer-japanese")]
462            TokenLexerWords::Lindera(token) => match token.next() {
463                Some(inner) => Some(inner.text),
464                None => None,
465            },
466        }
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    const NORMALIZATION_CONFIG: ConfigNormalization = ConfigNormalization {
475        diacritic_folding_enabled: false,
476        stemming_enabled: false,
477    };
478
479    #[test]
480    fn it_cleans_token_english() {
481        let mut token_cleaner = TokenLexerBuilder::from(
482            TokenLexerMode::NormalizeAndCleanup,
483            None,
484            "The quick brown fox jumps over the lazy dog!",
485            NORMALIZATION_CONFIG,
486        )
487        .unwrap();
488
489        assert_eq!(token_cleaner.locale, Some(Lang::Eng));
490        assert_eq!(
491            token_cleaner.next(),
492            Some(("quick".to_string(), 4179131656, 5))
493        );
494        assert_eq!(
495            token_cleaner.next(),
496            Some(("brown".to_string(), 1268820067, 5))
497        );
498        assert_eq!(
499            token_cleaner.next(),
500            Some(("fox".to_string(), 667256324, 3))
501        );
502        assert_eq!(
503            token_cleaner.next(),
504            Some(("jumps".to_string(), 633865164, 5))
505        );
506        assert_eq!(
507            token_cleaner.next(),
508            Some(("lazy".to_string(), 4130433347, 4))
509        );
510        assert_eq!(
511            token_cleaner.next(),
512            Some(("dog".to_string(), 2044924251, 3))
513        );
514        assert_eq!(token_cleaner.next(), None);
515    }
516
517    #[test]
518    fn it_cleans_token_french() {
519        let mut token_cleaner = TokenLexerBuilder::from(
520            TokenLexerMode::NormalizeAndCleanup,
521            None,
522            "Le vif renard brun saute par dessus le chien paresseux.",
523            NORMALIZATION_CONFIG,
524        )
525        .unwrap();
526
527        assert_eq!(token_cleaner.locale, Some(Lang::Fra));
528        assert_eq!(
529            token_cleaner.next(),
530            Some(("renard".to_string(), 1635186311, 6))
531        );
532        assert_eq!(
533            token_cleaner.next(),
534            Some(("brun".to_string(), 2763604928, 4))
535        );
536        assert_eq!(
537            token_cleaner.next(),
538            Some(("saute".to_string(), 1918158211, 5))
539        );
540        assert_eq!(
541            token_cleaner.next(),
542            Some(("chien".to_string(), 2177818351, 5))
543        );
544        assert_eq!(
545            token_cleaner.next(),
546            Some(("paresseux".to_string(), 1678693110, 9))
547        );
548        assert_eq!(token_cleaner.next(), None);
549    }
550
551    #[cfg(feature = "tokenizer-chinese")]
552    #[test]
553    fn it_cleans_token_chinese_jieba() {
554        let mut token_cleaner = TokenLexerBuilder::from(
555            TokenLexerMode::NormalizeAndCleanup,
556            None,
557            "我们中出了一个叛徒",
558            NORMALIZATION_CONFIG,
559        )
560        .unwrap();
561
562        assert_eq!(token_cleaner.locale, Some(Lang::Cmn));
563        assert_eq!(token_cleaner.next(), Some(("出".into(), 241978070, 3)));
564        assert_eq!(token_cleaner.next(), Some(("一个".into(), 2596274530, 6)));
565        assert_eq!(token_cleaner.next(), Some(("叛徒".into(), 3244183759, 6)));
566        assert_eq!(token_cleaner.next(), None);
567    }
568
569    #[cfg(not(feature = "tokenizer-chinese"))]
570    #[test]
571    fn it_cleans_token_chinese_naive() {
572        let mut token_cleaner = TokenLexerBuilder::from(
573            TokenLexerMode::NormalizeAndCleanup,
574            None,
575            "快狐跨懒狗快狐跨懒狗",
576            NORMALIZATION_CONFIG,
577        )
578        .unwrap();
579
580        assert_eq!(token_cleaner.locale, Some(Lang::Cmn));
581        assert_eq!(token_cleaner.next(), Some(("快".to_string(), 126546256, 3)));
582        assert_eq!(
583            token_cleaner.next(),
584            Some(("狐".to_string(), 2879689662, 3))
585        );
586        assert_eq!(
587            token_cleaner.next(),
588            Some(("跨".to_string(), 2913342670, 3))
589        );
590        assert_eq!(
591            token_cleaner.next(),
592            Some(("懒".to_string(), 3199935961, 3))
593        );
594        assert_eq!(
595            token_cleaner.next(),
596            Some(("狗".to_string(), 3360772096, 3))
597        );
598        assert_eq!(token_cleaner.next(), None);
599    }
600
601    #[cfg(feature = "tokenizer-japanese")]
602    #[test]
603    fn it_cleans_token_japanese_lindera_product() {
604        let mut token_cleaner = TokenLexerBuilder::from(
605            TokenLexerMode::NormalizeAndCleanup,
606            None,
607            "関西国際空港限定トートバッグ",
608            NORMALIZATION_CONFIG,
609        )
610        .unwrap();
611
612        assert_eq!(token_cleaner.locale, Some(Lang::Jpn));
613        assert_eq!(
614            token_cleaner.next(),
615            Some(("関西".to_string(), 1283572620, 6))
616        );
617        assert_eq!(
618            token_cleaner.next(),
619            Some(("国際".to_string(), 2132457693, 6))
620        );
621        assert_eq!(
622            token_cleaner.next(),
623            Some(("空港".to_string(), 865668138, 6))
624        );
625        assert_eq!(
626            token_cleaner.next(),
627            Some(("限定".to_string(), 3708465176, 6))
628        );
629        assert_eq!(
630            token_cleaner.next(),
631            Some(("トート".to_string(), 881444746, 9))
632        );
633        assert_eq!(
634            token_cleaner.next(),
635            Some(("バッグ".to_string(), 3515727814, 9))
636        );
637        assert_eq!(token_cleaner.next(), None);
638    }
639
640    #[cfg(feature = "tokenizer-japanese")]
641    #[test]
642    fn it_cleans_token_japanese_lindera_food() {
643        let token_cleaner = TokenLexerBuilder::from(
644            TokenLexerMode::NormalizeAndCleanup,
645            None,
646            "𠮷野家",
647            NORMALIZATION_CONFIG,
648        )
649        .unwrap();
650
651        assert_eq!(token_cleaner.locale, None);
652
653        let token_cleaner = TokenLexerBuilder::from(
654            TokenLexerMode::NormalizeAndCleanup,
655            None,
656            "ヱビスビール",
657            NORMALIZATION_CONFIG,
658        )
659        .unwrap();
660
661        assert_eq!(token_cleaner.locale, None);
662    }
663
664    #[cfg(feature = "tokenizer-japanese")]
665    #[test]
666    fn it_cleans_token_japanese_lindera_sentence() {
667        let mut token_cleaner = TokenLexerBuilder::from(
668            TokenLexerMode::NormalizeAndCleanup,
669            None,
670            "𠮷野家でヱビスビールを飲んだ",
671            NORMALIZATION_CONFIG,
672        )
673        .unwrap();
674
675        assert_eq!(token_cleaner.locale, Some(Lang::Jpn));
676        assert_eq!(
677            token_cleaner.next(),
678            Some(("𠮷".to_string(), 2866455824, 4))
679        );
680        assert_eq!(
681            token_cleaner.next(),
682            Some(("野家".to_string(), 1324395598, 6))
683        );
684        assert_eq!(
685            token_cleaner.next(),
686            Some(("ヱビス".to_string(), 1696836208, 9))
687        );
688        assert_eq!(
689            token_cleaner.next(),
690            Some(("ビール".to_string(), 3421909800, 9))
691        );
692        assert_eq!(
693            token_cleaner.next(),
694            Some(("飲ん".to_string(), 3196735184, 6))
695        );
696        assert_eq!(token_cleaner.next(), None);
697    }
698
699    #[test]
700    fn it_cleans_token_emojis() {
701        let mut token_cleaner = TokenLexerBuilder::from(
702            TokenLexerMode::NormalizeAndCleanup,
703            None,
704            "🚀 🙋‍♂️🙋‍♂️🙋‍♂️",
705            NORMALIZATION_CONFIG,
706        )
707        .unwrap();
708
709        assert_eq!(token_cleaner.locale, None);
710        assert_eq!(token_cleaner.next(), None);
711    }
712
713    #[test]
714    fn it_cleans_token_lang_hinted() {
715        let mut token_cleaner_right = TokenLexerBuilder::from(
716            TokenLexerMode::NormalizeAndCleanup,
717            Some(Lang::Eng),
718            "This will be cleaned properly, as English was hinted rightfully so.",
719            NORMALIZATION_CONFIG,
720        )
721        .unwrap();
722        let mut token_cleaner_wrong = TokenLexerBuilder::from(
723            TokenLexerMode::NormalizeAndCleanup,
724            Some(Lang::Fra),
725            "This will not be cleaned properly, as French was hinted but this is English.",
726            NORMALIZATION_CONFIG,
727        )
728        .unwrap();
729
730        assert_eq!(token_cleaner_right.locale, Some(Lang::Eng));
731        assert_eq!(token_cleaner_wrong.locale, Some(Lang::Fra));
732
733        assert_eq!(
734            token_cleaner_right.next(),
735            Some(("cleaned".to_string(), 3550382624, 7))
736        );
737        assert_eq!(
738            token_cleaner_wrong.next(),
739            Some(("this".to_string(), 493303710, 4))
740        );
741    }
742
743    #[test]
744    fn it_detects_lang_english_regular() {
745        assert_eq!(
746            TokenLexerBuilder::detect_lang("The quick brown fox jumps over the lazy dog!"),
747            Some(Lang::Eng)
748        );
749    }
750
751    #[test]
752    fn it_detects_lang_english_long() {
753        assert_eq!(
754            TokenLexerBuilder::detect_lang(
755                r#"Running an electrical current through water splits it into oxygen and hydrogen,
756            the latter of which can be used as a reliable, zero-emission fuel source. In the past,
757            the process of purifying water beforehand was too energy intensive for this process to
758            be useful — but now scientists have figured out how to skip the process altogether and
759            convert seawater into usable hydrogen"#
760            ),
761            Some(Lang::Eng)
762        );
763    }
764
765    #[test]
766    fn it_doesnt_detect_lang_english_tiny() {
767        assert_eq!(TokenLexerBuilder::detect_lang("The quick"), None);
768    }
769}
770
771#[cfg(all(feature = "benchmark", test))]
772mod benches {
773    extern crate test;
774
775    use super::*;
776    use test::Bencher;
777
778    #[bench]
779    fn bench_normalize_token_french_build(b: &mut Bencher) {
780        b.iter(|| {
781            TokenLexerBuilder::from(
782                TokenLexerMode::NormalizeOnly,
783                "Le vif renard brun saute par dessus le chien paresseux.",
784                NORMALIZATION_CONFIG,
785            )
786        });
787    }
788
789    #[bench]
790    fn bench_normalize_token_french_exhaust(b: &mut Bencher) {
791        b.iter(|| {
792            let token_cleaner = TokenLexerBuilder::from(
793                TokenLexerMode::NormalizeOnly,
794                "Le vif renard brun saute par dessus le chien paresseux.",
795                NORMALIZATION_CONFIG,
796            )
797            .unwrap();
798
799            token_cleaner.map(|value| value.1).collect::<Vec<u32>>()
800        });
801    }
802
803    #[bench]
804    fn bench_clean_token_english_regular_build(b: &mut Bencher) {
805        b.iter(|| {
806            TokenLexerBuilder::from(
807                TokenLexerMode::NormalizeAndCleanup,
808                None,
809                "The quick brown fox jumps over the lazy dog!",
810                NORMALIZATION_CONFIG,
811            )
812        });
813    }
814
815    #[bench]
816    fn bench_clean_token_english_regular_exhaust(b: &mut Bencher) {
817        b.iter(|| {
818            let token_cleaner = TokenLexerBuilder::from(
819                TokenLexerMode::NormalizeAndCleanup,
820                None,
821                "The quick brown fox jumps over the lazy dog!",
822                NORMALIZATION_CONFIG,
823            )
824            .unwrap();
825
826            token_cleaner.map(|value| value.1).collect::<Vec<u32>>()
827        });
828    }
829
830    #[bench]
831    fn bench_clean_token_english_long_exhaust(b: &mut Bencher) {
832        b.iter(|| {
833            let token_cleaner = TokenLexerBuilder::from(
834                TokenLexerMode::NormalizeAndCleanup,
835                None,
836                r#"Running an electrical current through water splits it into oxygen and hydrogen,
837                the latter of which can be used as a reliable, zero-emission fuel source. In the
838                past, the process of purifying water beforehand was too energy intensive for this
839                process to be useful — but now scientists have figured out how to skip the process
840                altogether and convert seawater into usable hydrogen"#,
841                NORMALIZATION_CONFIG,
842            )
843            .unwrap();
844
845            token_cleaner.map(|value| value.1).collect::<Vec<u32>>()
846        });
847    }
848
849    #[bench]
850    fn bench_clean_token_english_hinted_build(b: &mut Bencher) {
851        b.iter(|| {
852            TokenLexerBuilder::from(
853                TokenLexerMode::NormalizeAndCleanup(Some(Lang::Eng)),
854                "The quick brown fox jumps over the lazy dog!",
855                NORMALIZATION_CONFIG,
856            )
857        });
858    }
859
860    #[bench]
861    fn bench_clean_token_english_hinted_exhaust(b: &mut Bencher) {
862        b.iter(|| {
863            let token_cleaner = TokenLexerBuilder::from(
864                TokenLexerMode::NormalizeAndCleanup(Some(Lang::Eng)),
865                "The quick brown fox jumps over the lazy dog!",
866                NORMALIZATION_CONFIG,
867            )
868            .unwrap();
869
870            token_cleaner.map(|value| value.1).collect::<Vec<u32>>()
871        });
872    }
873
874    #[bench]
875    fn bench_clean_token_chinese_build(b: &mut Bencher) {
876        b.iter(|| {
877            TokenLexerBuilder::from(
878                TokenLexerMode::NormalizeAndCleanup,
879                None,
880                "我们中出了一个叛徒",
881                NORMALIZATION_CONFIG,
882            )
883        });
884    }
885
886    #[bench]
887    fn bench_clean_token_chinese_exhaust(b: &mut Bencher) {
888        b.iter(|| {
889            let token_cleaner = TokenLexerBuilder::from(
890                TokenLexerMode::NormalizeAndCleanup,
891                None,
892                "我们中出了一个叛徒",
893                NORMALIZATION_CONFIG,
894            )
895            .unwrap();
896
897            token_cleaner.map(|value| value.1).collect::<Vec<u32>>()
898        });
899    }
900
901    #[bench]
902    fn bench_clean_token_japanese_build(b: &mut Bencher) {
903        b.iter(|| {
904            TokenLexerBuilder::from(
905                TokenLexerMode::NormalizeAndCleanup,
906                None,
907                "関西国際空港限定トートバッグ",
908                NORMALIZATION_CONFIG,
909            )
910        });
911    }
912
913    #[bench]
914    fn bench_clean_token_japanese_exhaust(b: &mut Bencher) {
915        b.iter(|| {
916            let token_cleaner = TokenLexerBuilder::from(
917                TokenLexerMode::NormalizeAndCleanup,
918                None,
919                "関西国際空港限定トートバッグ",
920                NORMALIZATION_CONFIG,
921            )
922            .unwrap();
923
924            token_cleaner.map(|value| value.1).collect::<Vec<u32>>()
925        });
926    }
927
928    #[bench]
929    fn bench_detect_lang_english_short(b: &mut Bencher) {
930        b.iter(|| TokenLexerBuilder::detect_lang("The quick brown fox."));
931    }
932
933    #[bench]
934    fn bench_detect_lang_english_regular(b: &mut Bencher) {
935        b.iter(|| TokenLexerBuilder::detect_lang("The quick brown fox jumps over the lazy dog!"));
936    }
937
938    #[bench]
939    fn bench_detect_lang_english_long(b: &mut Bencher) {
940        b.iter(|| {
941            TokenLexerBuilder::detect_lang(
942                r#"Running an electrical current through water splits it into oxygen and hydrogen,
943            the latter of which can be used as a reliable, zero-emission fuel source. In the past,
944            the process of purifying water beforehand was too energy intensive for this process to
945            be useful — but now scientists have figured out how to skip the process altogether and
946            convert seawater into usable hydrogen"#,
947            )
948        });
949    }
950
951    #[bench]
952    fn bench_dont_detect_lang_english_tiny(b: &mut Bencher) {
953        b.iter(|| TokenLexerBuilder::detect_lang("The quick"));
954    }
955}