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, 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            // NOTE: It’s not truly an no-op but it is if we try matching a non-empty line.
57            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 we were walking words, continue.
99        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        // Check where the next special chunk is located.
110        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                // Up until that special chunk, tokenize normally.
117                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                // Once all normal words have been visited, yield the special
128                // chunk.
129                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                // Advance the iterator now that we’ve visited all previous
151                // tokens.
152                self.regex_matches.next();
153                self.regex_cursor = end;
154
155                next
156            }
157            None => {
158                // When there are no more special chunks, finish by tokenizing
159                // normally.
160                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    /// Any word, for which fuzzy matching can be applied.
177    Word(&'s str),
178
179    /// A special token, like an email address, which should not be fuzzy
180    /// matched.
181    Special {
182        raw: &'s str,
183        normalized: Cow<'s, str>,
184    },
185}
186
187impl<'s> Token<'s> {
188    fn special(captures: &regex::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    /// Any word, for which fuzzy matching can be applied.
219    Word(String),
220
221    /// A special token, like an email address, which should not be fuzzy
222    /// matched.
223    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            // If user provided a language, use it.
325            Some(hinted_lang) => {
326                // Use hinted language (current lexer mode asks for a cleanup)
327                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                // If user asked to cleanup, detect the language.
338                TokenLexerMode::NormalizeAndCleanup => {
339                    let locale = Self::detect_lang(text);
340                    tracing::debug!("detected locale: {:?} from lexer text: {}", locale, text);
341                    locale
342                }
343
344                // If user asked not to cleanup but stemming is enabled, detect the language.
345                #[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                // Otherwise, don’t detect the language.
353                TokenLexerMode::NormalizeOnly => {
354                    tracing::debug!("not detecting locale from lexer text: {}", text);
355
356                    None
357                }
358            },
359        };
360
361        // Build final token builder iterator
362        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        // Detect only if text is long-enough to allow the text locale detection system to \
376        //   function properly
377        if text.len() < TEXT_LANG_DETECT_PROCEED_OVER_CHARS {
378            return None;
379        }
380
381        // Truncate text if necessary, as to avoid the ngram or stopwords detector to be \
382        //   ran on more words than those that are enough to reliably detect a locale.
383        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            // Perform an UTF-8 aware truncation
392            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        // Attempt to detect the locale from text using an hybrid method that maximizes both \
401        //   accuracy and performance.
402        // Notice: as the 'ngram' method is almost 10x slower than the 'stopwords' method, we \
403        //   prefer using the 'stopwords' method on long texts where we can be sure to see quite \
404        //   a lot of stopwords which will produce a reliable result. However, for shorter texts \
405        //   there are not enough north none stopwords, thus we use the slower 'ngram' method as \
406        //   an attempt to extract the locale using trigrams. Still, if either of these methods \
407        //   fails at detecting a locale it will try using the other method in fallback as to \
408        //   produce the most reliable result while minimizing CPU cycles.
409        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                // Confidence is low, try to detect locale from stop-words.
446                // Notice: this is a fallback but should not be too reliable for short \
447                //   texts.
448                if !info.is_reliable() {
449                    tracing::debug!("[slow lexer] trying to detect locale from stopwords instead");
450
451                    // Better alternate locale found?
452                    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                // Locale found?
483                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                    // No locale found, fallback on slow ngram.
501                    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        // Tokenize words (depending on the locale)
526        let tokenizer = Tokenizer::new(text, locale, &tokenization_config);
527
528        // Identify Snowball algorithm now to avoid doing it for every token.
529        #[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                // Cleanup with provided language
553                TokenLexerMode::NormalizeAndCleanup
554            }
555            Some(QueryGenericLang::Disabled) => {
556                // Normalize only (language purposefully set to 'none')
557                TokenLexerMode::NormalizeOnly
558            }
559            None => {
560                // Auto-detect language and cleanup (this is the default behavior)
561                TokenLexerMode::NormalizeAndCleanup
562            }
563        }
564    }
565}
566
567impl<'a> Iterator for TokenLexer<'a> {
568    type Item = (NormalizedToken, StoreTermHashed, usize);
569
570    // Guarantees provided by the lexer on the output: \
571    //   - Text is split per-word in a script-aware way \
572    //   - Words are normalized (i.e. case is folded (≈ lower-cased), \
573    //     diacritics are optionally folded, word is opionally stemmed) \
574    //   - Gibberish words are removed (ie. words that may just be junk) \
575    //   - Stop-words are removed
576    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                    // NOTE: We use an iterator to avoid unnecessary `String`
586                    //   allocations.
587                    let mut chars: Box<dyn Iterator<Item = char>> = Box::new(original_word.chars());
588
589                    // Case folding
590                    {
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                    // Diacritic folding
605                    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                    // NOTE: We need to collect here as stemming algorithms need to
621                    //   lookup whole words.
622                    #[allow(unused_mut)]
623                    let mut new_word: String = chars.collect();
624
625                    // Stemming
626                    #[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            // Check if normalized word is a stop-word? (if should normalize and cleanup)
652            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            // Hash the term (this is used by all iterator consumers, as well as internally \
671            //   in the iterator to keep track of already-yielded words in a space-optimized \
672            //   manner, ie. by using 32-bit unsigned integer hashes)
673            let term_hash = StoreTermHash::from(&word);
674
675            // Check if word was not already yielded? (we return unique words)
676            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                // .inspect(|t| eprintln!("{t:?}"))
715                .take(256) // Breaks potential infinite loop.
716                .collect::<Vec<_>>();
717
718            assert_eq!(tokens, expected, "{sentence:?}");
719        }
720
721        // Email address.
722        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        // Phone number like.
745        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        // UUID like.
775        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        // Hash like.
789        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        // URL.
811        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        // Domain name.
826        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        // IP addresses.
852        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        // Username.
879        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        // Code like.
891        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}