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// Copyright: 2026, Rémi Bardon <remi@remibardon.name>
6// License: Mozilla Public License v2.0 (MPL v2.0)
7
8use 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            // NOTE: It’s not truly an no-op but it is if we try matching a non-empty line.
56            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 we were walking words, continue.
98        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        // Check where the next special chunk is located.
109        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                // Up until that special chunk, tokenize normally.
116                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                // Once all normal words have been visited, yield the special
127                // chunk.
128                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                // Advance the iterator now that we’ve visited all previous
150                // tokens.
151                self.regex_matches.next();
152                self.regex_cursor = end;
153
154                next
155            }
156            None => {
157                // When there are no more special chunks, finish by tokenizing
158                // normally.
159                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    /// Any word, for which fuzzy matching can be applied.
176    Word(&'s str),
177
178    /// A special token, like an email address, which should not be fuzzy
179    /// matched.
180    Special {
181        raw: &'s str,
182        normalized: Cow<'s, str>,
183    },
184}
185
186impl<'s> Token<'s> {
187    fn special(captures: &regex::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    /// Any word, for which fuzzy matching can be applied.
218    Word(String),
219
220    /// A special token, like an email address, which should not be fuzzy
221    /// matched.
222    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            // If user provided a language, use it.
322            Some(hinted_lang) => {
323                // Use hinted language (current lexer mode asks for a cleanup)
324                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                // If user asked to cleanup, detect the language.
335                TokenLexerMode::NormalizeAndCleanup => {
336                    let locale = Self::detect_lang(text);
337                    tracing::debug!("detected locale: {:?} from lexer text: {}", locale, text);
338                    locale
339                }
340
341                // If user asked not to cleanup but stemming is enabled, detect the language.
342                #[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                // Otherwise, don’t detect the language.
350                TokenLexerMode::NormalizeOnly => {
351                    tracing::debug!("not detecting locale from lexer text: {}", text);
352
353                    None
354                }
355            },
356        };
357
358        // Build final token builder iterator
359        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        // Detect only if text is long-enough to allow the text locale detection system to \
372        //   function properly
373        if text.len() < TEXT_LANG_DETECT_PROCEED_OVER_CHARS {
374            return None;
375        }
376
377        // Truncate text if necessary, as to avoid the ngram or stopwords detector to be \
378        //   ran on more words than those that are enough to reliably detect a locale.
379        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            // Perform an UTF-8 aware truncation
388            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        // Attempt to detect the locale from text using an hybrid method that maximizes both \
397        //   accuracy and performance.
398        // Notice: as the 'ngram' method is almost 10x slower than the 'stopwords' method, we \
399        //   prefer using the 'stopwords' method on long texts where we can be sure to see quite \
400        //   a lot of stopwords which will produce a reliable result. However, for shorter texts \
401        //   there are not enough north none stopwords, thus we use the slower 'ngram' method as \
402        //   an attempt to extract the locale using trigrams. Still, if either of these methods \
403        //   fails at detecting a locale it will try using the other method in fallback as to \
404        //   produce the most reliable result while minimizing CPU cycles.
405        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                // Confidence is low, try to detect locale from stop-words.
442                // Notice: this is a fallback but should not be too reliable for short \
443                //   texts.
444                if !info.is_reliable() {
445                    tracing::debug!("[slow lexer] trying to detect locale from stopwords instead");
446
447                    // Better alternate locale found?
448                    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                // Locale found?
479                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                    // No locale found, fallback on slow ngram.
497                    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        // Tokenize words (depending on the locale)
521        let tokenizer = Tokenizer::new(text, locale, &tokenization_config);
522
523        // Identify Snowball algorithm now to avoid doing it for every token.
524        #[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                // Cleanup with provided language
547                TokenLexerMode::NormalizeAndCleanup
548            }
549            Some(QueryGenericLang::Disabled) => {
550                // Normalize only (language purposefully set to 'none')
551                TokenLexerMode::NormalizeOnly
552            }
553            None => {
554                // Auto-detect language and cleanup (this is the default behavior)
555                TokenLexerMode::NormalizeAndCleanup
556            }
557        }
558    }
559}
560
561impl<'a> Iterator for TokenLexer<'a> {
562    type Item = (NormalizedToken, StoreTermHashed, usize);
563
564    // Guarantees provided by the lexer on the output: \
565    //   - Text is split per-word in a script-aware way \
566    //   - Words are normalized (i.e. case is folded (≈ lower-cased), \
567    //     diacritics are optionally folded, word is opionally stemmed) \
568    //   - Gibberish words are removed (ie. words that may just be junk) \
569    //   - Stop-words are removed
570    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                    // NOTE: We use an iterator to avoid unnecessary `String`
580                    //   allocations.
581                    let mut chars: Box<dyn Iterator<Item = char>> = Box::new(original_word.chars());
582
583                    // Case folding
584                    {
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                    // Diacritic folding
599                    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                    // NOTE: We need to collect here as stemming algorithms need to
615                    //   lookup whole words.
616                    #[allow(unused_mut)]
617                    let mut new_word: String = chars.collect();
618
619                    // Stemming
620                    #[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            // Check if normalized word is a stop-word? (if should normalize and cleanup)
646            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            // Hash the term (this is used by all iterator consumers, as well as internally \
652            //   in the iterator to keep track of already-yielded words in a space-optimized \
653            //   manner, ie. by using 32-bit unsigned integer hashes)
654            let term_hash = StoreTermHash::from(&word);
655
656            // Check if word was not already yielded? (we return unique words)
657            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                // .inspect(|t| eprintln!("{t:?}"))
691                .take(256) // Breaks potential infinite loop.
692                .collect::<Vec<_>>();
693
694            assert_eq!(tokens, expected, "{sentence:?}");
695        }
696
697        // Email address.
698        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        // Phone number like.
721        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        // UUID like.
751        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        // Hash like.
765        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        // URL.
787        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        // Domain name.
802        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        // IP addresses.
828        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        // Username.
855        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        // Code like.
867        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}