Skip to main content

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