Skip to main content

summa_core/tokenizer/
mod.rs

1//! Tokenizer API for text processing
2
3#[cfg(any(feature = "native", feature = "wasm"))]
4mod hf_tokenizer;
5
6mod cjk_morph;
7mod han_t2s;
8#[cfg(feature = "native")]
9mod idf_weights;
10mod lex;
11pub mod light_stem;
12
13pub use cjk_morph::{available as cjk_dictionaries_available, warm_up as warm_up_cjk_dictionaries};
14pub use lex::{
15    CjkMode, DEFAULT_MAX_TOKEN_LENGTH, HanForm, LexOptions, LexTokenizer, Segmenter, StemMode,
16    TokenizerSpec,
17};
18
19#[cfg(any(feature = "native", feature = "wasm"))]
20pub use hf_tokenizer::{HfTokenizer, TokenizerSource};
21
22#[cfg(feature = "native")]
23pub use hf_tokenizer::{TokenizerCache, tokenizer_cache};
24
25#[cfg(feature = "native")]
26pub use idf_weights::{IdfWeights, IdfWeightsCache, idf_weights_cache};
27
28use std::collections::HashMap;
29use std::sync::Arc;
30
31use parking_lot::RwLock;
32use rust_stemmers::Algorithm;
33use serde::{Deserialize, Serialize};
34use stop_words::Language as StopWordLanguage;
35
36/// What a tokenization is for.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Purpose {
39    /// Indexing: every form a document is findable by (originals plus
40    /// variants).
41    Index,
42    /// A match query: one form per word, the one that scores (the stem when
43    /// the language is known).
44    Match,
45    /// A phrase or exact term: the written form, as indexed.
46    Exact,
47}
48
49/// A token produced by tokenization
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct Token {
52    /// The text content of the token
53    pub text: String,
54    /// Position in the token stream (0-indexed)
55    pub position: u32,
56    /// Byte offset from start of original text
57    pub offset_from: usize,
58    /// Byte offset to end of token in original text
59    pub offset_to: usize,
60    /// A variant (stem, folded form, CJK bigram) sharing the position of an
61    /// original token: indexed like any term, but not counted as a token of
62    /// the document and never produced for queries.
63    pub variant: bool,
64}
65
66impl Token {
67    /// A same-position variant of an original token.
68    pub fn variant_of(text: String, position: u32, offset_from: usize, offset_to: usize) -> Self {
69        Self {
70            text,
71            position,
72            offset_from,
73            offset_to,
74            variant: true,
75        }
76    }
77
78    pub fn new(text: String, position: u32, offset_from: usize, offset_to: usize) -> Self {
79        Self {
80            text,
81            position,
82            offset_from,
83            offset_to,
84            variant: false,
85        }
86    }
87}
88
89/// Trait for tokenizers
90pub trait Tokenizer: Send + Sync + Clone + 'static {
91    /// Tokenize the input text into a vector of tokens
92    fn tokenize(&self, text: &str) -> Vec<Token>;
93
94    /// Tokenize with a caller-supplied hint and a purpose.
95    ///
96    /// Static tokenizers ignore both and have a single form. A
97    /// [`LexTokenizer`] reads the hint as a comma-separated list of language
98    /// codes (a sibling document field at index time, `tokenizer_hint` on
99    /// the query) and emits the forms the purpose asks for (see
100    /// [`Purpose`]).
101    fn tokenize_with(&self, text: &str, hint: Option<&str>, purpose: Purpose) -> Vec<Token> {
102        let _ = (hint, purpose);
103        self.tokenize(text)
104    }
105}
106
107/// Simple tokenizer — splits on whitespace, strips non-alphanumeric, and lowercases.
108///
109/// "Hello, World!" → ["hello", "world"]
110#[derive(Debug, Clone, Default)]
111pub struct SimpleTokenizer;
112
113impl Tokenizer for SimpleTokenizer {
114    fn tokenize(&self, text: &str) -> Vec<Token> {
115        tokenize_and_clean(text, std::convert::identity)
116    }
117}
118
119/// Unicode word boundaries followed by lowercase, without lexical rewriting.
120/// Internal punctuation follows UAX #29. Overlong words retain a position gap.
121#[derive(Debug, Clone, Default)]
122pub struct UnicodeWordTokenizer;
123
124impl Tokenizer for UnicodeWordTokenizer {
125    fn tokenize(&self, text: &str) -> Vec<Token> {
126        use unicode_segmentation::UnicodeSegmentation;
127        text.unicode_word_indices()
128            .enumerate()
129            .filter(|(_, (_, word))| word.chars().count() <= 255)
130            .map(|(position, (offset, word))| {
131                Token::new(
132                    word.to_lowercase(),
133                    position as u32,
134                    offset,
135                    offset + word.len(),
136                )
137            })
138            .collect()
139    }
140}
141
142/// Raw tokenizer — no tokenization at all.
143///
144/// The entire input text becomes a single token (trimmed).
145#[derive(Debug, Clone, Default)]
146pub struct RawTokenizer;
147
148impl Tokenizer for RawTokenizer {
149    fn tokenize(&self, text: &str) -> Vec<Token> {
150        let trimmed = text.trim();
151        if trimmed.is_empty() {
152            return Vec::new();
153        }
154        let offset = text.as_ptr() as usize;
155        let trimmed_offset = trimmed.as_ptr() as usize - offset;
156        vec![Token::new(
157            trimmed.to_string(),
158            0,
159            trimmed_offset,
160            trimmed_offset + trimmed.len(),
161        )]
162    }
163}
164
165/// Raw case-insensitive tokenizer — lowercases the entire input without splitting.
166///
167/// The entire input text becomes a single lowercased token (trimmed).
168#[derive(Debug, Clone, Default)]
169pub struct RawCiTokenizer;
170
171impl Tokenizer for RawCiTokenizer {
172    fn tokenize(&self, text: &str) -> Vec<Token> {
173        let trimmed = text.trim();
174        if trimmed.is_empty() {
175            return Vec::new();
176        }
177        let offset = text.as_ptr() as usize;
178        let trimmed_offset = trimmed.as_ptr() as usize - offset;
179        vec![Token::new(
180            lowercase_word(trimmed),
181            0,
182            trimmed_offset,
183            trimmed_offset + trimmed.len(),
184        )]
185    }
186}
187
188/// Lowercase a word, preserving all characters (no stripping).
189///
190/// ASCII fast-path avoids char decoding.
191#[inline]
192fn lowercase_word(word: &str) -> String {
193    if word.is_ascii() {
194        if word.bytes().all(|b| !b.is_ascii_uppercase()) {
195            return word.to_string();
196        }
197        let mut s = word.to_string();
198        s.make_ascii_lowercase();
199        s
200    } else {
201        word.chars().flat_map(|c| c.to_lowercase()).collect()
202    }
203}
204
205/// Strip non-alphanumeric characters and lowercase.
206///
207/// ASCII fast-path iterates bytes directly; falls back to full Unicode
208/// `char` iteration only when the word contains non-ASCII bytes.
209#[inline]
210pub(super) fn clean_word(word: &str) -> String {
211    if word.is_ascii() {
212        let bytes = word.as_bytes();
213        // Super-fast path: word is already lowercase alphanumeric → single memcpy
214        if bytes
215            .iter()
216            .all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit())
217        {
218            return word.to_string();
219        }
220        // ASCII path – byte iteration, no char decoding
221        let mut result = String::with_capacity(bytes.len());
222        for &b in bytes {
223            if b.is_ascii_alphanumeric() {
224                result.push(b.to_ascii_lowercase() as char);
225            }
226        }
227        result
228    } else {
229        // Unicode fallback
230        word.chars()
231            .filter(|c| c.is_alphanumeric())
232            .flat_map(|c| c.to_lowercase())
233            .collect()
234    }
235}
236
237/// Shared tokenization logic: split on whitespace, clean (remove punctuation + lowercase),
238/// then apply a transform function to produce the final token text.
239///
240/// Used by `SimpleTokenizer` (identity transform) and `StemmerTokenizer` (stem transform).
241/// The transform receives an owned `String` so identity transforms avoid extra allocations.
242fn tokenize_and_clean(text: &str, transform: impl Fn(String) -> String) -> Vec<Token> {
243    tokenize_and_clean_filtered(text, |word| Some(transform(word)))
244}
245
246/// Like [`tokenize_and_clean`], but `transform` may drop a word by returning
247/// `None`. A dropped word still consumes a position, so the surviving tokens
248/// keep their original distances (a phrase indexed as `quantum@0 art@3`
249/// keeps the gap of the two stop words between them).
250fn tokenize_and_clean_filtered(
251    text: &str,
252    transform: impl Fn(String) -> Option<String>,
253) -> Vec<Token> {
254    let mut tokens = Vec::with_capacity(text.len() / 5);
255    let mut position = 0u32;
256    for (offset, word) in split_whitespace_with_offsets(text) {
257        if !word.is_empty() {
258            let cleaned = clean_word(word);
259            if !cleaned.is_empty() {
260                if let Some(text) = transform(cleaned) {
261                    tokens.push(Token::new(text, position, offset, offset + word.len()));
262                }
263                position += 1;
264            }
265        }
266    }
267    tokens
268}
269
270/// Split text on whitespace, returning (byte-offset, word) pairs.
271///
272/// Uses pointer arithmetic on the subslices returned by `split_whitespace`
273/// instead of the previous O(n)-per-word `find()` approach.
274pub(super) fn split_whitespace_with_offsets(text: &str) -> impl Iterator<Item = (usize, &str)> {
275    let base = text.as_ptr() as usize;
276    text.split_whitespace()
277        .map(move |word| (word.as_ptr() as usize - base, word))
278}
279
280/// Supported stemmer languages
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
282#[allow(missing_docs)]
283#[derive(Default)]
284pub enum Language {
285    Arabic,
286    Danish,
287    Dutch,
288    #[default]
289    English,
290    Finnish,
291    French,
292    German,
293    Greek,
294    Hungarian,
295    Italian,
296    Norwegian,
297    Portuguese,
298    Romanian,
299    Russian,
300    Spanish,
301    Swedish,
302    Tamil,
303    Turkish,
304}
305
306impl Language {
307    fn to_algorithm(self) -> Algorithm {
308        match self {
309            Language::Arabic => Algorithm::Arabic,
310            Language::Danish => Algorithm::Danish,
311            Language::Dutch => Algorithm::Dutch,
312            Language::English => Algorithm::English,
313            Language::Finnish => Algorithm::Finnish,
314            Language::French => Algorithm::French,
315            Language::German => Algorithm::German,
316            Language::Greek => Algorithm::Greek,
317            Language::Hungarian => Algorithm::Hungarian,
318            Language::Italian => Algorithm::Italian,
319            Language::Norwegian => Algorithm::Norwegian,
320            Language::Portuguese => Algorithm::Portuguese,
321            Language::Romanian => Algorithm::Romanian,
322            Language::Russian => Algorithm::Russian,
323            Language::Spanish => Algorithm::Spanish,
324            Language::Swedish => Algorithm::Swedish,
325            Language::Tamil => Algorithm::Tamil,
326            Language::Turkish => Algorithm::Turkish,
327        }
328    }
329
330    pub(super) fn to_stop_words_language(self) -> StopWordLanguage {
331        match self {
332            Language::Arabic => StopWordLanguage::Arabic,
333            Language::Danish => StopWordLanguage::Danish,
334            Language::Dutch => StopWordLanguage::Dutch,
335            Language::English => StopWordLanguage::English,
336            Language::Finnish => StopWordLanguage::Finnish,
337            Language::French => StopWordLanguage::French,
338            Language::German => StopWordLanguage::German,
339            Language::Greek => StopWordLanguage::Greek,
340            Language::Hungarian => StopWordLanguage::Hungarian,
341            Language::Italian => StopWordLanguage::Italian,
342            Language::Norwegian => StopWordLanguage::Norwegian,
343            Language::Portuguese => StopWordLanguage::Portuguese,
344            Language::Romanian => StopWordLanguage::Romanian,
345            Language::Russian => StopWordLanguage::Russian,
346            Language::Spanish => StopWordLanguage::Spanish,
347            Language::Swedish => StopWordLanguage::Swedish,
348            Language::Tamil => StopWordLanguage::Tamil,
349            Language::Turkish => StopWordLanguage::Turkish,
350        }
351    }
352}
353
354/// Stop word filter tokenizer - wraps another tokenizer and filters out stop words
355///
356/// Uses the stop-words crate for language-specific stop word lists.
357#[derive(Debug, Clone)]
358pub struct StopWordTokenizer<T: Tokenizer> {
359    inner: T,
360    stop_words: HashSet<String>,
361}
362
363use std::collections::HashSet;
364
365impl<T: Tokenizer> StopWordTokenizer<T> {
366    /// Create a new stop word tokenizer wrapping the given tokenizer
367    pub fn new(inner: T, language: Language) -> Self {
368        let stop_words: HashSet<String> = stop_words::get(language.to_stop_words_language())
369            .iter()
370            .map(|s| s.to_string())
371            .collect();
372        Self { inner, stop_words }
373    }
374
375    /// Create with English stop words
376    pub fn english(inner: T) -> Self {
377        Self::new(inner, Language::English)
378    }
379
380    /// Create with custom stop words
381    pub fn with_custom_stop_words(inner: T, stop_words: HashSet<String>) -> Self {
382        Self { inner, stop_words }
383    }
384
385    /// Check if a word is a stop word
386    pub fn is_stop_word(&self, word: &str) -> bool {
387        self.stop_words.contains(word)
388    }
389}
390
391impl<T: Tokenizer> Tokenizer for StopWordTokenizer<T> {
392    fn tokenize(&self, text: &str) -> Vec<Token> {
393        self.inner
394            .tokenize(text)
395            .into_iter()
396            .filter(|token| !self.stop_words.contains(token.text.as_str()))
397            .collect()
398    }
399}
400
401/// Stemming tokenizer - splits on whitespace, lowercases, and applies stemming
402///
403/// Uses the Snowball stemming algorithm via rust-stemmers.
404/// Supports multiple languages including English, German, French, Spanish, etc.
405#[derive(Debug, Clone)]
406pub struct StemmerTokenizer {
407    language: Language,
408}
409
410impl StemmerTokenizer {
411    /// Create a new stemmer tokenizer for the given language
412    pub fn new(language: Language) -> Self {
413        Self { language }
414    }
415
416    /// Create a new English stemmer tokenizer
417    pub fn english() -> Self {
418        Self::new(Language::English)
419    }
420}
421
422impl Default for StemmerTokenizer {
423    fn default() -> Self {
424        Self::english()
425    }
426}
427
428impl Tokenizer for StemmerTokenizer {
429    fn tokenize(&self, text: &str) -> Vec<Token> {
430        let stemmer = rust_stemmers::Stemmer::create(self.language.to_algorithm());
431        tokenize_and_clean(text, |s| stemmer.stem(&s).into_owned())
432    }
433}
434
435/// Multi-language stemmer that can select language dynamically
436///
437/// This tokenizer holds stemmers for multiple languages and can tokenize
438/// text using a specific language selected at runtime.
439#[derive(Debug, Clone)]
440pub struct MultiLanguageStemmer {
441    default_language: Language,
442}
443
444impl MultiLanguageStemmer {
445    /// Create a new multi-language stemmer with the given default language
446    pub fn new(default_language: Language) -> Self {
447        Self { default_language }
448    }
449
450    /// Tokenize text using a specific language
451    pub fn tokenize_with_language(&self, text: &str, language: Language) -> Vec<Token> {
452        let stemmer = rust_stemmers::Stemmer::create(language.to_algorithm());
453        tokenize_and_clean(text, |s| stemmer.stem(&s).into_owned())
454    }
455
456    /// Get the default language
457    pub fn default_language(&self) -> Language {
458        self.default_language
459    }
460}
461
462impl Default for MultiLanguageStemmer {
463    fn default() -> Self {
464        Self::new(Language::English)
465    }
466}
467
468impl Tokenizer for MultiLanguageStemmer {
469    fn tokenize(&self, text: &str) -> Vec<Token> {
470        self.tokenize_with_language(text, self.default_language)
471    }
472}
473
474/// Language-aware tokenizer that can be configured per-field
475///
476/// This allows selecting the stemmer language based on document metadata,
477/// such as a "language" field in the document.
478#[derive(Clone)]
479pub struct LanguageAwareTokenizer<F>
480where
481    F: Fn(&str) -> Language + Clone + Send + Sync + 'static,
482{
483    language_selector: F,
484    stemmer: MultiLanguageStemmer,
485}
486
487impl<F> LanguageAwareTokenizer<F>
488where
489    F: Fn(&str) -> Language + Clone + Send + Sync + 'static,
490{
491    /// Create a new language-aware tokenizer with a custom language selector
492    ///
493    /// The selector function receives a language hint (e.g., from a document field)
494    /// and returns the appropriate Language to use for stemming.
495    ///
496    /// # Example
497    /// ```ignore
498    /// let tokenizer = LanguageAwareTokenizer::new(|hint| {
499    ///     match hint {
500    ///         "en" | "english" => Language::English,
501    ///         "de" | "german" => Language::German,
502    ///         "ru" | "russian" => Language::Russian,
503    ///         _ => Language::English,
504    ///     }
505    /// });
506    /// ```
507    pub fn new(language_selector: F) -> Self {
508        Self {
509            language_selector,
510            stemmer: MultiLanguageStemmer::default(),
511        }
512    }
513
514    /// Tokenize text with a language hint
515    ///
516    /// The hint is passed to the language selector to determine which stemmer to use.
517    pub fn tokenize_with_hint(&self, text: &str, language_hint: &str) -> Vec<Token> {
518        let language = (self.language_selector)(language_hint);
519        self.stemmer.tokenize_with_language(text, language)
520    }
521}
522
523impl<F> Tokenizer for LanguageAwareTokenizer<F>
524where
525    F: Fn(&str) -> Language + Clone + Send + Sync + 'static,
526{
527    fn tokenize(&self, text: &str) -> Vec<Token> {
528        // Default to English when no hint is provided
529        self.stemmer.tokenize_with_language(text, Language::English)
530    }
531
532    fn tokenize_with(&self, text: &str, hint: Option<&str>, _purpose: Purpose) -> Vec<Token> {
533        match hint {
534            Some(hint) => self.tokenize_with_hint(text, hint),
535            None => Tokenizer::tokenize(self, text),
536        }
537    }
538}
539
540/// Parse a language string into a Language enum
541///
542/// Supports common language codes and names.
543pub fn parse_language(s: &str) -> Language {
544    match s.to_lowercase().as_str() {
545        "ar" | "arabic" => Language::Arabic,
546        "da" | "danish" => Language::Danish,
547        "nl" | "dutch" => Language::Dutch,
548        "en" | "english" => Language::English,
549        "fi" | "finnish" => Language::Finnish,
550        "fr" | "french" => Language::French,
551        "de" | "german" => Language::German,
552        "el" | "greek" => Language::Greek,
553        "hu" | "hungarian" => Language::Hungarian,
554        "it" | "italian" => Language::Italian,
555        "no" | "norwegian" => Language::Norwegian,
556        "pt" | "portuguese" => Language::Portuguese,
557        "ro" | "romanian" => Language::Romanian,
558        "ru" | "russian" => Language::Russian,
559        "es" | "spanish" => Language::Spanish,
560        "sv" | "swedish" => Language::Swedish,
561        "ta" | "tamil" => Language::Tamil,
562        "tr" | "turkish" => Language::Turkish,
563        _ => Language::English, // Default fallback
564    }
565}
566
567/// Parse a language string into a Language, returning `None` for unknown values.
568///
569/// Accepts ISO 639-1 codes and English language names, case-insensitively.
570/// Unlike [`parse_language`], unknown input is not silently mapped to English.
571pub fn parse_language_opt(s: &str) -> Option<Language> {
572    Some(match s.trim().to_lowercase().as_str() {
573        "ar" | "arabic" => Language::Arabic,
574        "da" | "danish" => Language::Danish,
575        "nl" | "dutch" => Language::Dutch,
576        "en" | "english" => Language::English,
577        "fi" | "finnish" => Language::Finnish,
578        "fr" | "french" => Language::French,
579        "de" | "german" => Language::German,
580        "el" | "greek" => Language::Greek,
581        "hu" | "hungarian" => Language::Hungarian,
582        "it" | "italian" => Language::Italian,
583        "no" | "norwegian" => Language::Norwegian,
584        "pt" | "portuguese" => Language::Portuguese,
585        "ro" | "romanian" => Language::Romanian,
586        "ru" | "russian" => Language::Russian,
587        "es" | "spanish" => Language::Spanish,
588        "sv" | "swedish" => Language::Swedish,
589        "ta" | "tamil" => Language::Tamil,
590        "tr" | "turkish" => Language::Turkish,
591        _ => return None,
592    })
593}
594
595/// Writing system of a token, used to route it to a stemmer of the same script.
596#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
597pub enum Script {
598    Latin,
599    Cyrillic,
600    Greek,
601    Arabic,
602    Tamil,
603    /// Any other script (CJK, Hebrew, digits-only tokens, ...)
604    Other,
605}
606
607impl Script {
608    /// Script of the first alphabetic character of `token`; `Other` when none.
609    pub fn of_token(token: &str) -> Script {
610        let Some(c) = token.chars().find(|c| c.is_alphabetic()) else {
611            return Script::Other;
612        };
613        Script::of_char(c)
614    }
615
616    fn of_char(c: char) -> Script {
617        match c as u32 {
618            // Basic Latin, Latin-1 Supplement, Latin Extended-A/B, Latin Extended Additional
619            0x0041..=0x024F | 0x1E00..=0x1EFF => Script::Latin,
620            0x0370..=0x03FF | 0x1F00..=0x1FFF => Script::Greek,
621            0x0400..=0x052F => Script::Cyrillic,
622            0x0600..=0x06FF | 0x0750..=0x077F | 0x08A0..=0x08FF => Script::Arabic,
623            0x0B80..=0x0BFF => Script::Tamil,
624            _ => Script::Other,
625        }
626    }
627}
628
629impl Language {
630    /// Script the Snowball algorithm of this language operates on.
631    pub fn script(self) -> Script {
632        match self {
633            Language::Russian => Script::Cyrillic,
634            Language::Greek => Script::Greek,
635            Language::Arabic => Script::Arabic,
636            Language::Tamil => Script::Tamil,
637            _ => Script::Latin,
638        }
639    }
640}
641
642thread_local! {
643    static STEMMER_CACHE: std::cell::RefCell<HashMap<Language, rust_stemmers::Stemmer>> =
644        std::cell::RefCell::new(HashMap::new());
645}
646
647/// Run `f` with the cached Snowball stemmers for `languages`, in order.
648///
649/// The thread-local cache is borrowed once per tokenization call instead of
650/// once per token, so the hot loop pays no `RefCell` borrow or hash lookup.
651pub(super) fn with_stemmers<R>(
652    languages: &[Language],
653    f: impl FnOnce(&[&rust_stemmers::Stemmer]) -> R,
654) -> R {
655    STEMMER_CACHE.with(|cache| {
656        let mut cache = cache.borrow_mut();
657        for language in languages {
658            cache
659                .entry(*language)
660                .or_insert_with(|| rust_stemmers::Stemmer::create(language.to_algorithm()));
661        }
662        let stemmers: Vec<&rust_stemmers::Stemmer> =
663            languages.iter().map(|language| &cache[language]).collect();
664        f(&stemmers)
665    })
666}
667
668/// ISO 639-1 code of a stemmer language.
669pub fn language_code(language: Language) -> &'static str {
670    match language {
671        Language::Arabic => "ar",
672        Language::Danish => "da",
673        Language::Dutch => "nl",
674        Language::English => "en",
675        Language::Finnish => "fi",
676        Language::French => "fr",
677        Language::German => "de",
678        Language::Greek => "el",
679        Language::Hungarian => "hu",
680        Language::Italian => "it",
681        Language::Norwegian => "no",
682        Language::Portuguese => "pt",
683        Language::Romanian => "ro",
684        Language::Russian => "ru",
685        Language::Spanish => "es",
686        Language::Swedish => "sv",
687        Language::Tamil => "ta",
688        Language::Turkish => "tr",
689    }
690}
691
692/// Boxed tokenizer for dynamic dispatch
693pub type BoxedTokenizer = Box<dyn TokenizerClone>;
694
695pub trait TokenizerClone: Send + Sync {
696    fn tokenize(&self, text: &str) -> Vec<Token>;
697    /// See [`Tokenizer::tokenize_with`].
698    fn tokenize_with(&self, text: &str, hint: Option<&str>, purpose: Purpose) -> Vec<Token>;
699    fn clone_box(&self) -> BoxedTokenizer;
700}
701
702impl<T: Tokenizer> TokenizerClone for T {
703    fn tokenize(&self, text: &str) -> Vec<Token> {
704        Tokenizer::tokenize(self, text)
705    }
706
707    fn tokenize_with(&self, text: &str, hint: Option<&str>, purpose: Purpose) -> Vec<Token> {
708        Tokenizer::tokenize_with(self, text, hint, purpose)
709    }
710
711    fn clone_box(&self) -> BoxedTokenizer {
712        Box::new(self.clone())
713    }
714}
715
716impl Clone for BoxedTokenizer {
717    fn clone(&self) -> Self {
718        self.clone_box()
719    }
720}
721
722/// Registry for named tokenizers
723///
724/// Allows registering tokenizers by name and retrieving them for use during indexing.
725/// Pre-registers common tokenizers: "simple", "raw", "raw_ci", "en_stem", etc.
726#[derive(Clone)]
727pub struct TokenizerRegistry {
728    tokenizers: Arc<RwLock<HashMap<String, BoxedTokenizer>>>,
729    /// Parsed `lex(...)` specs, keyed by their spec string. Query conversion
730    /// resolves the field tokenizer on every request; parsing the spec each
731    /// time was measurable at query rates.
732    dynamic: Arc<RwLock<HashMap<String, BoxedTokenizer>>>,
733}
734
735impl TokenizerRegistry {
736    /// Create a new tokenizer registry with default tokenizers registered
737    pub fn new() -> Self {
738        let registry = Self {
739            tokenizers: Arc::new(RwLock::new(HashMap::new())),
740            dynamic: Arc::new(RwLock::new(HashMap::new())),
741        };
742        registry.register_defaults();
743        registry
744    }
745
746    /// Register default tokenizers
747    fn register_defaults(&self) {
748        // Basic tokenizers ("default" is the documented alias of "simple")
749        self.register("simple", SimpleTokenizer);
750        self.register("default", SimpleTokenizer);
751        self.register("unicode_word", UnicodeWordTokenizer);
752        self.register("raw", RawTokenizer);
753        self.register("raw_ci", RawCiTokenizer);
754
755        // English stemmer variants
756        self.register("en_stem", StemmerTokenizer::new(Language::English));
757        self.register("english", StemmerTokenizer::new(Language::English));
758
759        // Other language stemmers
760        self.register("ar_stem", StemmerTokenizer::new(Language::Arabic));
761        self.register("arabic", StemmerTokenizer::new(Language::Arabic));
762        self.register("da_stem", StemmerTokenizer::new(Language::Danish));
763        self.register("danish", StemmerTokenizer::new(Language::Danish));
764        self.register("nl_stem", StemmerTokenizer::new(Language::Dutch));
765        self.register("dutch", StemmerTokenizer::new(Language::Dutch));
766        self.register("fi_stem", StemmerTokenizer::new(Language::Finnish));
767        self.register("finnish", StemmerTokenizer::new(Language::Finnish));
768        self.register("fr_stem", StemmerTokenizer::new(Language::French));
769        self.register("french", StemmerTokenizer::new(Language::French));
770        self.register("de_stem", StemmerTokenizer::new(Language::German));
771        self.register("german", StemmerTokenizer::new(Language::German));
772        self.register("el_stem", StemmerTokenizer::new(Language::Greek));
773        self.register("greek", StemmerTokenizer::new(Language::Greek));
774        self.register("hu_stem", StemmerTokenizer::new(Language::Hungarian));
775        self.register("hungarian", StemmerTokenizer::new(Language::Hungarian));
776        self.register("it_stem", StemmerTokenizer::new(Language::Italian));
777        self.register("italian", StemmerTokenizer::new(Language::Italian));
778        self.register("no_stem", StemmerTokenizer::new(Language::Norwegian));
779        self.register("norwegian", StemmerTokenizer::new(Language::Norwegian));
780        self.register("pt_stem", StemmerTokenizer::new(Language::Portuguese));
781        self.register("portuguese", StemmerTokenizer::new(Language::Portuguese));
782        self.register("ro_stem", StemmerTokenizer::new(Language::Romanian));
783        self.register("romanian", StemmerTokenizer::new(Language::Romanian));
784        self.register("ru_stem", StemmerTokenizer::new(Language::Russian));
785        self.register("russian", StemmerTokenizer::new(Language::Russian));
786        self.register("es_stem", StemmerTokenizer::new(Language::Spanish));
787        self.register("spanish", StemmerTokenizer::new(Language::Spanish));
788        self.register("sv_stem", StemmerTokenizer::new(Language::Swedish));
789        self.register("swedish", StemmerTokenizer::new(Language::Swedish));
790        self.register("ta_stem", StemmerTokenizer::new(Language::Tamil));
791        self.register("tamil", StemmerTokenizer::new(Language::Tamil));
792        self.register("tr_stem", StemmerTokenizer::new(Language::Turkish));
793        self.register("turkish", StemmerTokenizer::new(Language::Turkish));
794
795        // Stop word filtered tokenizers (lowercase + stop words)
796        self.register(
797            "en_stop",
798            StopWordTokenizer::new(SimpleTokenizer, Language::English),
799        );
800        self.register(
801            "de_stop",
802            StopWordTokenizer::new(SimpleTokenizer, Language::German),
803        );
804        self.register(
805            "fr_stop",
806            StopWordTokenizer::new(SimpleTokenizer, Language::French),
807        );
808        self.register(
809            "ru_stop",
810            StopWordTokenizer::new(SimpleTokenizer, Language::Russian),
811        );
812        self.register(
813            "es_stop",
814            StopWordTokenizer::new(SimpleTokenizer, Language::Spanish),
815        );
816
817        // Stop word + stemming tokenizers
818        self.register(
819            "en_stem_stop",
820            StopWordTokenizer::new(StemmerTokenizer::new(Language::English), Language::English),
821        );
822        self.register(
823            "de_stem_stop",
824            StopWordTokenizer::new(StemmerTokenizer::new(Language::German), Language::German),
825        );
826        self.register(
827            "fr_stem_stop",
828            StopWordTokenizer::new(StemmerTokenizer::new(Language::French), Language::French),
829        );
830        self.register(
831            "ru_stem_stop",
832            StopWordTokenizer::new(StemmerTokenizer::new(Language::Russian), Language::Russian),
833        );
834        self.register(
835            "es_stem_stop",
836            StopWordTokenizer::new(StemmerTokenizer::new(Language::Spanish), Language::Spanish),
837        );
838    }
839
840    /// Register a tokenizer with a name
841    pub fn register<T: Tokenizer>(&self, name: &str, tokenizer: T) {
842        let mut tokenizers = self.tokenizers.write();
843        tokenizers.insert(name.to_string(), Box::new(tokenizer));
844    }
845
846    /// Get a tokenizer by name or by a `lex(by: ..., ...)` spec.
847    ///
848    /// Dynamic specs are parsed once per distinct spec string and cached; a
849    /// malformed spec is not cached and yields `None` on every call.
850    pub fn get(&self, name: &str) -> Option<BoxedTokenizer> {
851        if name.starts_with("lex(") {
852            if let Some(tokenizer) = self.dynamic.read().get(name) {
853                return Some(tokenizer.clone());
854            }
855            let tokenizer = TokenizerSpec::parse(name)
856                .ok()
857                .and_then(|spec| spec.dynamic_tokenizer())?;
858            self.dynamic
859                .write()
860                .entry(name.to_string())
861                .or_insert_with(|| tokenizer.clone());
862            return Some(tokenizer);
863        }
864        let tokenizers = self.tokenizers.read();
865        tokenizers.get(name).cloned()
866    }
867
868    /// Check if a tokenizer is registered
869    pub fn contains(&self, name: &str) -> bool {
870        let tokenizers = self.tokenizers.read();
871        tokenizers.contains_key(name)
872    }
873
874    /// List all registered tokenizer names
875    pub fn names(&self) -> Vec<String> {
876        let tokenizers = self.tokenizers.read();
877        tokenizers.keys().cloned().collect()
878    }
879}
880
881impl Default for TokenizerRegistry {
882    fn default() -> Self {
883        Self::new()
884    }
885}
886
887#[cfg(test)]
888mod tests {
889    use super::*;
890
891    #[test]
892    fn test_simple_tokenizer() {
893        let tokenizer = SimpleTokenizer;
894        let tokens = Tokenizer::tokenize(&tokenizer, "Hello World");
895
896        assert_eq!(tokens.len(), 2);
897        assert_eq!(tokens[0].text, "hello");
898        assert_eq!(tokens[0].position, 0);
899        assert_eq!(tokens[1].text, "world");
900        assert_eq!(tokens[1].position, 1);
901    }
902
903    #[test]
904    fn test_raw_tokenizer() {
905        let tokenizer = RawTokenizer;
906        // Entire input becomes one token, preserving case and punctuation
907        let tokens = Tokenizer::tokenize(&tokenizer, "Hello, World!");
908        assert_eq!(tokens.len(), 1);
909        assert_eq!(tokens[0].text, "Hello, World!");
910        assert_eq!(tokens[0].position, 0);
911    }
912
913    #[test]
914    fn test_raw_tokenizer_trims() {
915        let tokenizer = RawTokenizer;
916        let tokens = Tokenizer::tokenize(&tokenizer, "  spaced  ");
917        assert_eq!(tokens.len(), 1);
918        assert_eq!(tokens[0].text, "spaced");
919        assert_eq!(tokens[0].offset_from, 2);
920    }
921
922    #[test]
923    fn test_raw_tokenizer_empty() {
924        let tokenizer = RawTokenizer;
925        assert!(Tokenizer::tokenize(&tokenizer, "").is_empty());
926        assert!(Tokenizer::tokenize(&tokenizer, "   ").is_empty());
927    }
928
929    #[test]
930    fn test_raw_ci_tokenizer() {
931        let tokenizer = RawCiTokenizer;
932        // Entire input lowercased as one token
933        let tokens = Tokenizer::tokenize(&tokenizer, "Hello, World!");
934        assert_eq!(tokens.len(), 1);
935        assert_eq!(tokens[0].text, "hello, world!");
936        assert_eq!(tokens[0].position, 0);
937    }
938
939    #[test]
940    fn test_raw_ci_tokenizer_preserves_structure() {
941        let tokenizer = RawCiTokenizer;
942        let tokens = Tokenizer::tokenize(&tokenizer, "HTTPS://Example.COM/Page");
943        assert_eq!(tokens.len(), 1);
944        assert_eq!(tokens[0].text, "https://example.com/page");
945    }
946
947    #[test]
948    fn test_simple_tokenizer_strips_punctuation() {
949        let tokenizer = SimpleTokenizer;
950        let tokens = Tokenizer::tokenize(&tokenizer, "Hello, World!");
951
952        assert_eq!(tokens.len(), 2);
953        assert_eq!(tokens[0].text, "hello");
954        assert_eq!(tokens[1].text, "world");
955    }
956
957    #[test]
958    fn unicode_words_preserve_internal_punctuation_and_original_offsets() {
959        let tokenizer = TokenizerRegistry::new().get("unicode_word").unwrap();
960        let input = "TERM_start5 User:Robert books.google.com John's CAFÉ e\u{301}";
961        let tokens = tokenizer.tokenize(input);
962        let expected = [
963            "term_start5",
964            "user:robert",
965            "books.google.com",
966            "john's",
967            "café",
968            "e\u{301}",
969        ];
970        assert_eq!(tokens.len(), expected.len());
971        for (i, (token, expected)) in tokens.iter().zip(expected).enumerate() {
972            assert_eq!(token.text, expected);
973            assert_eq!(token.position, i as u32);
974            assert_eq!(
975                input[token.offset_from..token.offset_to].to_lowercase(),
976                expected
977            );
978            assert!(!token.variant);
979        }
980        let input = format!("left {} right", "x".repeat(256));
981        let tokens = tokenizer.tokenize(&input);
982        assert_eq!(
983            tokens
984                .iter()
985                .map(|t| (t.text.as_str(), t.position))
986                .collect::<Vec<_>>(),
987            vec![("left", 0), ("right", 2)]
988        );
989    }
990
991    #[test]
992    fn test_empty_text() {
993        let tokenizer = SimpleTokenizer;
994        let tokens = Tokenizer::tokenize(&tokenizer, "");
995        assert!(tokens.is_empty());
996    }
997
998    #[test]
999    fn test_stemmer_tokenizer_english() {
1000        let tokenizer = StemmerTokenizer::english();
1001        let tokens = Tokenizer::tokenize(&tokenizer, "Dogs are running quickly");
1002
1003        assert_eq!(tokens.len(), 4);
1004        assert_eq!(tokens[0].text, "dog"); // dogs -> dog
1005        assert_eq!(tokens[1].text, "are"); // are -> are
1006        assert_eq!(tokens[2].text, "run"); // running -> run
1007        assert_eq!(tokens[3].text, "quick"); // quickly -> quick
1008    }
1009
1010    #[test]
1011    fn test_stemmer_tokenizer_preserves_offsets() {
1012        let tokenizer = StemmerTokenizer::english();
1013        let tokens = Tokenizer::tokenize(&tokenizer, "Running dogs");
1014
1015        assert_eq!(tokens.len(), 2);
1016        assert_eq!(tokens[0].text, "run");
1017        assert_eq!(tokens[0].offset_from, 0);
1018        assert_eq!(tokens[0].offset_to, 7); // "Running" is 7 chars
1019        assert_eq!(tokens[1].text, "dog");
1020        assert_eq!(tokens[1].offset_from, 8);
1021        assert_eq!(tokens[1].offset_to, 12); // "dogs" is 4 chars
1022    }
1023
1024    #[test]
1025    fn test_stemmer_tokenizer_german() {
1026        let tokenizer = StemmerTokenizer::new(Language::German);
1027        let tokens = Tokenizer::tokenize(&tokenizer, "Häuser Bücher");
1028
1029        assert_eq!(tokens.len(), 2);
1030        // German stemmer should stem these plural forms
1031        assert_eq!(tokens[0].text, "haus"); // häuser -> haus
1032        assert_eq!(tokens[1].text, "buch"); // bücher -> buch
1033    }
1034
1035    #[test]
1036    fn test_stemmer_tokenizer_russian() {
1037        let tokenizer = StemmerTokenizer::new(Language::Russian);
1038        let tokens = Tokenizer::tokenize(&tokenizer, "бегущие собаки");
1039
1040        assert_eq!(tokens.len(), 2);
1041        // Russian stemmer should stem these
1042        assert_eq!(tokens[0].text, "бегущ"); // бегущие -> бегущ
1043        assert_eq!(tokens[1].text, "собак"); // собаки -> собак
1044    }
1045
1046    #[test]
1047    fn test_multi_language_stemmer() {
1048        let stemmer = MultiLanguageStemmer::new(Language::English);
1049
1050        // Test with English
1051        let tokens = stemmer.tokenize_with_language("running dogs", Language::English);
1052        assert_eq!(tokens[0].text, "run");
1053        assert_eq!(tokens[1].text, "dog");
1054
1055        // Test with German
1056        let tokens = stemmer.tokenize_with_language("Häuser Bücher", Language::German);
1057        assert_eq!(tokens[0].text, "haus");
1058        assert_eq!(tokens[1].text, "buch");
1059
1060        // Test with Russian
1061        let tokens = stemmer.tokenize_with_language("бегущие собаки", Language::Russian);
1062        assert_eq!(tokens[0].text, "бегущ");
1063        assert_eq!(tokens[1].text, "собак");
1064    }
1065
1066    #[test]
1067    fn test_language_aware_tokenizer() {
1068        let tokenizer = LanguageAwareTokenizer::new(parse_language);
1069
1070        // English hint
1071        let tokens = tokenizer.tokenize_with_hint("running dogs", "en");
1072        assert_eq!(tokens[0].text, "run");
1073        assert_eq!(tokens[1].text, "dog");
1074
1075        // German hint
1076        let tokens = tokenizer.tokenize_with_hint("Häuser Bücher", "de");
1077        assert_eq!(tokens[0].text, "haus");
1078        assert_eq!(tokens[1].text, "buch");
1079
1080        // Russian hint
1081        let tokens = tokenizer.tokenize_with_hint("бегущие собаки", "russian");
1082        assert_eq!(tokens[0].text, "бегущ");
1083        assert_eq!(tokens[1].text, "собак");
1084    }
1085
1086    #[test]
1087    fn test_parse_language() {
1088        assert_eq!(parse_language("en"), Language::English);
1089        assert_eq!(parse_language("english"), Language::English);
1090        assert_eq!(parse_language("English"), Language::English);
1091        assert_eq!(parse_language("de"), Language::German);
1092        assert_eq!(parse_language("german"), Language::German);
1093        assert_eq!(parse_language("ru"), Language::Russian);
1094        assert_eq!(parse_language("russian"), Language::Russian);
1095        assert_eq!(parse_language("unknown"), Language::English); // fallback
1096    }
1097
1098    #[test]
1099    fn test_tokenizer_registry_defaults() {
1100        let registry = TokenizerRegistry::new();
1101
1102        // Check default tokenizers are registered
1103        assert!(registry.contains("simple"));
1104        assert!(registry.contains("raw"));
1105        assert!(registry.contains("raw_ci"));
1106        assert!(registry.contains("raw"));
1107        assert!(registry.contains("raw_ci"));
1108        assert!(registry.contains("en_stem"));
1109        assert!(registry.contains("german"));
1110        assert!(registry.contains("russian"));
1111    }
1112
1113    #[test]
1114    fn test_tokenizer_registry_get() {
1115        let registry = TokenizerRegistry::new();
1116
1117        // Get and use a tokenizer
1118        let tokenizer = registry.get("en_stem").unwrap();
1119        let tokens = tokenizer.tokenize("running dogs");
1120        assert_eq!(tokens[0].text, "run");
1121        assert_eq!(tokens[1].text, "dog");
1122
1123        // Get German stemmer
1124        let tokenizer = registry.get("german").unwrap();
1125        let tokens = tokenizer.tokenize("Häuser Bücher");
1126        assert_eq!(tokens[0].text, "haus");
1127        assert_eq!(tokens[1].text, "buch");
1128    }
1129
1130    #[test]
1131    fn test_tokenizer_registry_custom() {
1132        let registry = TokenizerRegistry::new();
1133
1134        // Register a custom tokenizer
1135        registry.register("my_tokenizer", SimpleTokenizer);
1136
1137        assert!(registry.contains("my_tokenizer"));
1138        let tokenizer = registry.get("my_tokenizer").unwrap();
1139        let tokens = tokenizer.tokenize("Hello World");
1140        assert_eq!(tokens[0].text, "hello");
1141        assert_eq!(tokens[1].text, "world");
1142    }
1143
1144    #[test]
1145    fn test_tokenizer_registry_nonexistent() {
1146        let registry = TokenizerRegistry::new();
1147        assert!(registry.get("nonexistent").is_none());
1148    }
1149
1150    #[test]
1151    fn test_stop_word_tokenizer_english() {
1152        let tokenizer = StopWordTokenizer::english(SimpleTokenizer);
1153        let tokens = Tokenizer::tokenize(&tokenizer, "The quick brown fox jumps over the lazy dog");
1154
1155        // "the", "over" are stop words and should be filtered
1156        let texts: Vec<&str> = tokens.iter().map(|t| t.text.as_str()).collect();
1157        assert!(!texts.contains(&"the"));
1158        assert!(!texts.contains(&"over"));
1159        assert!(texts.contains(&"quick"));
1160        assert!(texts.contains(&"brown"));
1161        assert!(texts.contains(&"fox"));
1162        assert!(texts.contains(&"jumps"));
1163        assert!(texts.contains(&"lazy"));
1164        assert!(texts.contains(&"dog"));
1165    }
1166
1167    #[test]
1168    fn test_stop_word_tokenizer_with_stemmer() {
1169        // Note: StopWordTokenizer filters AFTER stemming, so stop words
1170        // that get stemmed may not be filtered. For proper stop word + stemming,
1171        // filter stop words before stemming or use a stemmed stop word list.
1172        let tokenizer = StopWordTokenizer::new(StemmerTokenizer::english(), Language::English);
1173        let tokens = Tokenizer::tokenize(&tokenizer, "elephants galaxies quantum");
1174
1175        let texts: Vec<&str> = tokens.iter().map(|t| t.text.as_str()).collect();
1176        // Stemmed forms should be present (these are not stop words)
1177        assert!(texts.contains(&"eleph")); // elephants -> eleph
1178        assert!(texts.contains(&"galaxi")); // galaxies -> galaxi
1179        assert!(texts.contains(&"quantum")); // quantum -> quantum
1180    }
1181
1182    #[test]
1183    fn test_stop_word_tokenizer_german() {
1184        let tokenizer = StopWordTokenizer::new(SimpleTokenizer, Language::German);
1185        let tokens = Tokenizer::tokenize(&tokenizer, "Der Hund und die Katze");
1186
1187        // "der", "und", "die" are German stop words
1188        let texts: Vec<&str> = tokens.iter().map(|t| t.text.as_str()).collect();
1189        assert!(!texts.contains(&"der"));
1190        assert!(!texts.contains(&"und"));
1191        assert!(!texts.contains(&"die"));
1192        assert!(texts.contains(&"hund"));
1193        assert!(texts.contains(&"katze"));
1194    }
1195
1196    #[test]
1197    fn test_stop_word_tokenizer_custom() {
1198        let custom_stops: HashSet<String> = ["foo", "bar"].iter().map(|s| s.to_string()).collect();
1199        let tokenizer = StopWordTokenizer::with_custom_stop_words(SimpleTokenizer, custom_stops);
1200        let tokens = Tokenizer::tokenize(&tokenizer, "foo baz bar qux");
1201
1202        let texts: Vec<&str> = tokens.iter().map(|t| t.text.as_str()).collect();
1203        assert!(!texts.contains(&"foo"));
1204        assert!(!texts.contains(&"bar"));
1205        assert!(texts.contains(&"baz"));
1206        assert!(texts.contains(&"qux"));
1207    }
1208
1209    #[test]
1210    fn test_stop_word_tokenizer_is_stop_word() {
1211        let tokenizer = StopWordTokenizer::english(SimpleTokenizer);
1212        assert!(tokenizer.is_stop_word("the"));
1213        assert!(tokenizer.is_stop_word("and"));
1214        assert!(tokenizer.is_stop_word("is"));
1215        // These are definitely not stop words
1216        assert!(!tokenizer.is_stop_word("elephant"));
1217        assert!(!tokenizer.is_stop_word("quantum"));
1218    }
1219
1220    #[test]
1221    fn test_tokenizer_registry_stop_word_tokenizers() {
1222        let registry = TokenizerRegistry::new();
1223
1224        // Check stop word tokenizers are registered
1225        assert!(registry.contains("en_stop"));
1226        assert!(registry.contains("en_stem_stop"));
1227        assert!(registry.contains("de_stop"));
1228        assert!(registry.contains("ru_stop"));
1229
1230        // Test en_stop filters stop words
1231        let tokenizer = registry.get("en_stop").unwrap();
1232        let tokens = tokenizer.tokenize("The quick fox");
1233        let texts: Vec<&str> = tokens.iter().map(|t| t.text.as_str()).collect();
1234        assert!(!texts.contains(&"the"));
1235        assert!(texts.contains(&"quick"));
1236        assert!(texts.contains(&"fox"));
1237
1238        // Test en_stem_stop filters stop words AND stems
1239        let tokenizer = registry.get("en_stem_stop").unwrap();
1240        let tokens = tokenizer.tokenize("elephants galaxies");
1241        let texts: Vec<&str> = tokens.iter().map(|t| t.text.as_str()).collect();
1242        assert!(texts.contains(&"eleph")); // stemmed
1243        assert!(texts.contains(&"galaxi")); // stemmed
1244    }
1245
1246    fn hinted<T: Tokenizer>(tokenizer: &T, text: &str, hint: Option<&str>) -> Vec<Token> {
1247        Tokenizer::tokenize_with(tokenizer, text, hint, Purpose::Index)
1248    }
1249
1250    fn lex(spec: &str) -> LexTokenizer {
1251        LexTokenizer::new(LexOptions::parse(spec).unwrap())
1252    }
1253
1254    fn texts(tokens: &[Token]) -> Vec<&str> {
1255        tokens.iter().map(|t| t.text.as_str()).collect()
1256    }
1257
1258    fn positions(tokens: &[Token]) -> Vec<u32> {
1259        tokens.iter().map(|t| t.position).collect()
1260    }
1261
1262    #[test]
1263    fn unicode_segmenter_splits_on_word_boundaries_and_folds() {
1264        let plain = lex("by: languages, segmenter: unicode, stem: snowball, variants: false");
1265        let tokens = hinted(
1266            &plain,
1267            "Float-zero determinants: p53/CO2, 10.1007/s1 résumé",
1268            None,
1269        );
1270        assert_eq!(
1271            texts(&tokens),
1272            vec![
1273                "float",
1274                "zero",
1275                "determinants",
1276                "p53",
1277                "co2",
1278                "10.1007",
1279                "s1",
1280                "resume"
1281            ]
1282        );
1283        assert_eq!(positions(&tokens), (0..8).collect::<Vec<u32>>());
1284        // Offsets still point at the original words.
1285        assert_eq!(
1286            &"Float-zero determinants: p53/CO2, 10.1007/s1 résumé"
1287                [tokens[7].offset_from..tokens[7].offset_to],
1288            "résumé"
1289        );
1290
1291        // Stemming sees the original letters, folding runs afterwards, for
1292        // every segmenter.
1293        let english = lex("by: languages, segmenter: unicode, stem: snowball, variants: false");
1294        assert_eq!(
1295            texts(&hinted(&english, "Running foxes' café", Some("en"))),
1296            vec!["run", "fox", "cafe"]
1297        );
1298        assert_eq!(
1299            texts(&hinted(
1300                &lex("by: languages, segmenter: simple, stem: snowball, variants: false"),
1301                "Float-zero café",
1302                Some("en")
1303            )),
1304            vec!["float", "zero", "cafe"]
1305        );
1306        // Cyrillic folding drops combining marks but keeps the letters.
1307        assert_eq!(texts(&hinted(&plain, "ёлка", None)), vec!["елка"]);
1308        // Stop words are removed before folding and keep their gap.
1309        let stopping = lex(
1310            "by: languages, stop_words: true, segmenter: unicode, stem: snowball, variants: false",
1311        );
1312        let tokens = hinted(&stopping, "state-of-the-art résumé", Some("en"));
1313        assert_eq!(tokens.len(), 3, "{:?}", texts(&tokens));
1314        assert_eq!(&texts(&tokens)[..2], ["state", "art"]);
1315        assert!(tokens[2].text.starts_with("resum"), "{:?}", tokens[2].text);
1316        assert!(tokens[2].text.is_ascii(), "folded after stemming");
1317        assert_eq!(positions(&tokens), vec![0, 3, 4]);
1318    }
1319
1320    #[test]
1321    fn unicode_segmenter_bigrams_cjk_runs() {
1322        let t = lex("by: languages, segmenter: unicode, stem: snowball, variants: false");
1323        let tokens = hinted(&t, "東京都 tower", Some("en"));
1324        assert_eq!(texts(&tokens), vec!["東京", "京都", "tower"]);
1325        assert_eq!(positions(&tokens), vec![0, 1, 2]);
1326        assert_eq!(
1327            &"東京都 tower"[tokens[1].offset_from..tokens[1].offset_to],
1328            "京都"
1329        );
1330        // A lone ideograph is its own token; runs split at any non-CJK word.
1331        assert_eq!(
1332            texts(&hinted(&t, "東 tower 京都", None)),
1333            vec!["東", "tower", "京都"]
1334        );
1335        // Katakana runs are bigrammed too, and a run stops where the text
1336        // stops being contiguous.
1337        assert_eq!(
1338            texts(&hinted(&t, "トウキョウ タワー", None)),
1339            vec!["トウ", "ウキ", "キョ", "ョウ", "タワ", "ワー"]
1340        );
1341        // Mixed script without spaces: the ideographs form one run, the
1342        // Latin word follows.
1343        assert_eq!(texts(&hinted(&t, "東京tower", None)), vec!["東京", "tower"]);
1344        // Phrase offsets follow the bigram positions.
1345        let query = hinted(&t, "東京都", None);
1346        assert_eq!(positions(&query), vec![0, 1]);
1347    }
1348
1349    #[test]
1350    fn dynamic_stemmer_drops_stop_words_but_keeps_positions() {
1351        let stemmer = lex(
1352            "by: languages, stop_words: true, segmenter: simple, stem: snowball, variants: false",
1353        );
1354        let tokens = hinted(&stemmer, "Quantum of the Art", Some("en"));
1355        assert_eq!(texts(&tokens), vec!["quantum", "art"]);
1356        assert_eq!(positions(&tokens), vec![0, 3]);
1357        // Byte offsets still point at the surviving words.
1358        assert_eq!(tokens[1].offset_from, "Quantum of the ".len());
1359
1360        // Stop words are matched before stemming, per script-routed language.
1361        let tokens = hinted(&stemmer, "бегущие и собаки the foxes", Some("ru,en"));
1362        assert_eq!(texts(&tokens), vec!["бегущ", "собак", "fox"]);
1363        assert_eq!(positions(&tokens), vec![0, 2, 4]);
1364
1365        // Tokens of a script no hinted language covers are untouched.
1366        assert_eq!(
1367            texts(&hinted(&stemmer, "the 日本語 fox", Some("en"))),
1368            vec!["日本語", "fox"]
1369        );
1370
1371        // No hint and no default: nothing is stemmed and nothing is dropped.
1372        assert_eq!(
1373            texts(&hinted(&stemmer, "the fox", None)),
1374            vec!["the", "fox"]
1375        );
1376        // A default language applies its stop list too.
1377        let english = lex(
1378            "by: languages, default: en, stop_words: true, segmenter: simple, stem: snowball, variants: false",
1379        );
1380        assert_eq!(texts(&hinted(&english, "the fox", None)), vec!["fox"]);
1381        // Off by default.
1382        assert_eq!(
1383            texts(&hinted(
1384                &lex("by: languages, segmenter: simple, stem: snowball, variants: false"),
1385                "the fox",
1386                Some("en")
1387            )),
1388            vec!["the", "fox"]
1389        );
1390        // Only stop words: no tokens at all.
1391        assert!(hinted(&stemmer, "to be or not to be", Some("en")).is_empty());
1392    }
1393
1394    #[test]
1395    fn dynamic_stemmer_selects_language_from_hint() {
1396        let stemmer = lex("by: languages, segmenter: simple, stem: snowball, variants: false");
1397        assert_eq!(
1398            texts(&hinted(&stemmer, "Running Foxes", Some("en"))),
1399            vec!["run", "fox"]
1400        );
1401        assert_eq!(
1402            texts(&hinted(&stemmer, "бегущие собаки", Some("ru"))),
1403            vec!["бегущ", "собак"]
1404        );
1405        // Unknown hint and no hint both fall back to the default (simple).
1406        assert_eq!(
1407            texts(&hinted(&stemmer, "Running Foxes", Some("xx"))),
1408            vec!["running", "foxes"]
1409        );
1410        assert_eq!(
1411            texts(&hinted(&stemmer, "Running Foxes", None)),
1412            vec!["running", "foxes"]
1413        );
1414        assert_eq!(
1415            texts(&Tokenizer::tokenize(&stemmer, "Running, Foxes!")),
1416            vec!["running", "foxes"]
1417        );
1418        // A default language applies when no hint is given.
1419        let english =
1420            lex("by: languages, default: en, segmenter: simple, stem: snowball, variants: false");
1421        assert_eq!(
1422            texts(&hinted(&english, "Running Foxes", None)),
1423            vec!["run", "fox"]
1424        );
1425    }
1426
1427    #[test]
1428    fn dynamic_stemmer_routes_tokens_by_script() {
1429        let stemmer = lex("by: languages, segmenter: simple, stem: snowball, variants: false");
1430        // Mixed-script text: each token goes to the hinted language of its script.
1431        assert_eq!(
1432            texts(&hinted(&stemmer, "бегущие foxes", Some("ru,en"))),
1433            vec!["бегущ", "fox"]
1434        );
1435        assert_eq!(
1436            texts(&hinted(&stemmer, "бегущие foxes", Some("en, ru"))),
1437            vec!["бегущ", "fox"]
1438        );
1439        // A single hinted language never touches tokens of another script.
1440        assert_eq!(
1441            texts(&hinted(&stemmer, "бегущие foxes", Some("ru"))),
1442            vec!["бегущ", "foxes"]
1443        );
1444        assert_eq!(
1445            texts(&hinted(&stemmer, "бегущие foxes", Some("en"))),
1446            vec!["бегущие", "fox"]
1447        );
1448        // Same-script languages: the first listed one wins.
1449        assert_eq!(
1450            texts(&hinted(&stemmer, "running", Some("de,en"))),
1451            vec!["running"]
1452        );
1453        assert_eq!(
1454            texts(&hinted(&stemmer, "running", Some("en,de"))),
1455            vec!["run"]
1456        );
1457        // Positions stay sequential across scripts.
1458        let tokens = hinted(&stemmer, "бегущие foxes run", Some("ru,en"));
1459        assert_eq!(
1460            tokens.iter().map(|t| t.position).collect::<Vec<_>>(),
1461            vec![0, 1, 2]
1462        );
1463    }
1464
1465    #[test]
1466    fn script_detection_covers_supported_stemmer_scripts() {
1467        assert_eq!(Script::of_token("hello"), Script::Latin);
1468        assert_eq!(Script::of_token("straße"), Script::Latin);
1469        assert_eq!(Script::of_token("собака"), Script::Cyrillic);
1470        assert_eq!(Script::of_token("γεια"), Script::Greek);
1471        assert_eq!(Script::of_token("مرحبا"), Script::Arabic);
1472        assert_eq!(Script::of_token("தமிழ்"), Script::Tamil);
1473        assert_eq!(Script::of_token("日本語"), Script::Other);
1474        assert_eq!(Script::of_token("2024"), Script::Other);
1475        assert_eq!(Language::Russian.script(), Script::Cyrillic);
1476        assert_eq!(Language::Turkish.script(), Script::Latin);
1477    }
1478
1479    #[test]
1480    fn registry_builds_lex_tokenizers_from_specs() {
1481        let registry = TokenizerRegistry::new();
1482        let tokenizer = registry
1483            .get("lex(by: languages, segmenter: simple, stem: snowball, variants: false)")
1484            .expect("lex spec resolves without registration");
1485        assert_eq!(
1486            texts(&tokenizer.tokenize_with("Running Foxes", Some("en"), Purpose::Index)),
1487            vec!["run", "fox"]
1488        );
1489        assert_eq!(
1490            texts(&tokenizer.tokenize("Running Foxes")),
1491            vec!["running", "foxes"]
1492        );
1493        assert!(
1494            registry
1495                .get("lex(by: languages, default: klingon)")
1496                .is_none()
1497        );
1498        let stopping = registry
1499            .get("lex(by: languages, stop_words: true, segmenter: simple, stem: snowball, variants: false)")
1500            .expect("stop-word spec resolves");
1501        let tokens = stopping.tokenize_with("the running foxes", Some("en"), Purpose::Index);
1502        assert_eq!(texts(&tokens), vec!["run", "fox"]);
1503        assert_eq!(positions(&tokens), vec![1, 2]);
1504        // Static tokenizers accept and ignore hints and purposes.
1505        let simple = registry.get("en_stem").unwrap();
1506        assert_eq!(
1507            texts(&simple.tokenize_with("Running Foxes", Some("ru"), Purpose::Exact)),
1508            vec!["run", "fox"]
1509        );
1510    }
1511
1512    #[test]
1513    fn spec_without_by_is_a_fixed_tokenizer_that_ignores_hints() {
1514        let spec = TokenizerSpec::parse("lex(stop_words: true, stem: none)").unwrap();
1515        assert_eq!(spec.hint_field(), None);
1516        let tokenizer = spec.dynamic_tokenizer().unwrap();
1517        let plain_tokens = tokenizer.tokenize("The running cells");
1518        let plain = texts(&plain_tokens);
1519        // No language: nothing is stemmed and no stop list applies.
1520        assert_eq!(plain, vec!["the", "running", "cells"]);
1521        let hinted_tokens =
1522            tokenizer.tokenize_with("The running cells", Some("en"), Purpose::Index);
1523        assert_eq!(
1524            texts(&hinted_tokens),
1525            plain,
1526            "hints are ignored without `by`"
1527        );
1528
1529        // A fixed default language stems every document and query alike.
1530        let english = TokenizerSpec::parse(
1531            "lex(default: en, stop_words: true, stem: snowball, variants: false)",
1532        )
1533        .unwrap();
1534        let tokenizer = english.dynamic_tokenizer().unwrap();
1535        assert_eq!(
1536            texts(&tokenizer.tokenize_with("The running cells", Some("ru"), Purpose::Index)),
1537            vec!["run", "cell"]
1538        );
1539    }
1540
1541    #[test]
1542    fn parse_language_opt_rejects_unknown_codes() {
1543        assert_eq!(parse_language_opt(" RU "), Some(Language::Russian));
1544        assert_eq!(parse_language_opt("german"), Some(Language::German));
1545        assert_eq!(parse_language_opt("xx"), None);
1546        assert_eq!(parse_language_opt(""), None);
1547        for language in [Language::English, Language::Russian, Language::Tamil] {
1548            assert_eq!(parse_language_opt(language_code(language)), Some(language));
1549        }
1550    }
1551}