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
6#[cfg(feature = "native")]
7mod idf_weights;
8
9#[cfg(any(feature = "native", feature = "wasm"))]
10pub use hf_tokenizer::{HfTokenizer, TokenizerSource};
11
12#[cfg(feature = "native")]
13pub use hf_tokenizer::{TokenizerCache, tokenizer_cache};
14
15#[cfg(feature = "native")]
16pub use idf_weights::{IdfWeights, IdfWeightsCache, idf_weights_cache};
17
18use std::collections::HashMap;
19use std::sync::Arc;
20
21use parking_lot::RwLock;
22use rust_stemmers::Algorithm;
23use serde::{Deserialize, Serialize};
24use stop_words::LANGUAGE;
25
26/// A token produced by tokenization
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct Token {
29    /// The text content of the token
30    pub text: String,
31    /// Position in the token stream (0-indexed)
32    pub position: u32,
33    /// Byte offset from start of original text
34    pub offset_from: usize,
35    /// Byte offset to end of token in original text
36    pub offset_to: usize,
37}
38
39impl Token {
40    pub fn new(text: String, position: u32, offset_from: usize, offset_to: usize) -> Self {
41        Self {
42            text,
43            position,
44            offset_from,
45            offset_to,
46        }
47    }
48}
49
50/// Trait for tokenizers
51pub trait Tokenizer: Send + Sync + Clone + 'static {
52    /// Tokenize the input text into a vector of tokens
53    fn tokenize(&self, text: &str) -> Vec<Token>;
54
55    /// Tokenize with an optional caller-supplied hint.
56    ///
57    /// Static tokenizers ignore the hint. Dynamic tokenizers (see
58    /// [`DynamicStemmer`]) interpret it — e.g. as a comma-separated list of
59    /// language codes taken from a sibling document field at index time and
60    /// from `tokenizer_hint` on the query at search time.
61    fn tokenize_hinted(&self, text: &str, hint: Option<&str>) -> Vec<Token> {
62        let _ = hint;
63        self.tokenize(text)
64    }
65}
66
67/// Simple tokenizer — splits on whitespace, strips non-alphanumeric, and lowercases.
68///
69/// "Hello, World!" → ["hello", "world"]
70#[derive(Debug, Clone, Default)]
71pub struct SimpleTokenizer;
72
73impl Tokenizer for SimpleTokenizer {
74    fn tokenize(&self, text: &str) -> Vec<Token> {
75        tokenize_and_clean(text, std::convert::identity)
76    }
77}
78
79/// Raw tokenizer — no tokenization at all.
80///
81/// The entire input text becomes a single token (trimmed).
82#[derive(Debug, Clone, Default)]
83pub struct RawTokenizer;
84
85impl Tokenizer for RawTokenizer {
86    fn tokenize(&self, text: &str) -> Vec<Token> {
87        let trimmed = text.trim();
88        if trimmed.is_empty() {
89            return Vec::new();
90        }
91        let offset = text.as_ptr() as usize;
92        let trimmed_offset = trimmed.as_ptr() as usize - offset;
93        vec![Token::new(
94            trimmed.to_string(),
95            0,
96            trimmed_offset,
97            trimmed_offset + trimmed.len(),
98        )]
99    }
100}
101
102/// Raw case-insensitive tokenizer — lowercases the entire input without splitting.
103///
104/// The entire input text becomes a single lowercased token (trimmed).
105#[derive(Debug, Clone, Default)]
106pub struct RawCiTokenizer;
107
108impl Tokenizer for RawCiTokenizer {
109    fn tokenize(&self, text: &str) -> Vec<Token> {
110        let trimmed = text.trim();
111        if trimmed.is_empty() {
112            return Vec::new();
113        }
114        let offset = text.as_ptr() as usize;
115        let trimmed_offset = trimmed.as_ptr() as usize - offset;
116        vec![Token::new(
117            lowercase_word(trimmed),
118            0,
119            trimmed_offset,
120            trimmed_offset + trimmed.len(),
121        )]
122    }
123}
124
125/// Lowercase a word, preserving all characters (no stripping).
126///
127/// ASCII fast-path avoids char decoding.
128#[inline]
129fn lowercase_word(word: &str) -> String {
130    if word.is_ascii() {
131        if word.bytes().all(|b| !b.is_ascii_uppercase()) {
132            return word.to_string();
133        }
134        let mut s = word.to_string();
135        s.make_ascii_lowercase();
136        s
137    } else {
138        word.chars().flat_map(|c| c.to_lowercase()).collect()
139    }
140}
141
142/// Strip non-alphanumeric characters and lowercase.
143///
144/// ASCII fast-path iterates bytes directly; falls back to full Unicode
145/// `char` iteration only when the word contains non-ASCII bytes.
146#[inline]
147fn clean_word(word: &str) -> String {
148    if word.is_ascii() {
149        let bytes = word.as_bytes();
150        // Super-fast path: word is already lowercase alphanumeric → single memcpy
151        if bytes
152            .iter()
153            .all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit())
154        {
155            return word.to_string();
156        }
157        // ASCII path – byte iteration, no char decoding
158        let mut result = String::with_capacity(bytes.len());
159        for &b in bytes {
160            if b.is_ascii_alphanumeric() {
161                result.push(b.to_ascii_lowercase() as char);
162            }
163        }
164        result
165    } else {
166        // Unicode fallback
167        word.chars()
168            .filter(|c| c.is_alphanumeric())
169            .flat_map(|c| c.to_lowercase())
170            .collect()
171    }
172}
173
174/// Shared tokenization logic: split on whitespace, clean (remove punctuation + lowercase),
175/// then apply a transform function to produce the final token text.
176///
177/// Used by `SimpleTokenizer` (identity transform) and `StemmerTokenizer` (stem transform).
178/// The transform receives an owned `String` so identity transforms avoid extra allocations.
179fn tokenize_and_clean(text: &str, transform: impl Fn(String) -> String) -> Vec<Token> {
180    tokenize_and_clean_filtered(text, |word| Some(transform(word)))
181}
182
183/// Like [`tokenize_and_clean`], but `transform` may drop a word by returning
184/// `None`. A dropped word still consumes a position, so the surviving tokens
185/// keep their original distances (a phrase indexed as `quantum@0 art@3`
186/// keeps the gap of the two stop words between them).
187fn tokenize_and_clean_filtered(
188    text: &str,
189    transform: impl Fn(String) -> Option<String>,
190) -> Vec<Token> {
191    let mut tokens = Vec::with_capacity(text.len() / 5);
192    let mut position = 0u32;
193    for (offset, word) in split_whitespace_with_offsets(text) {
194        if !word.is_empty() {
195            let cleaned = clean_word(word);
196            if !cleaned.is_empty() {
197                if let Some(text) = transform(cleaned) {
198                    tokens.push(Token::new(text, position, offset, offset + word.len()));
199                }
200                position += 1;
201            }
202        }
203    }
204    tokens
205}
206
207/// Split text on whitespace, returning (byte-offset, word) pairs.
208///
209/// Uses pointer arithmetic on the subslices returned by `split_whitespace`
210/// instead of the previous O(n)-per-word `find()` approach.
211fn split_whitespace_with_offsets(text: &str) -> impl Iterator<Item = (usize, &str)> {
212    let base = text.as_ptr() as usize;
213    text.split_whitespace()
214        .map(move |word| (word.as_ptr() as usize - base, word))
215}
216
217/// Supported stemmer languages
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
219#[allow(missing_docs)]
220#[derive(Default)]
221pub enum Language {
222    Arabic,
223    Danish,
224    Dutch,
225    #[default]
226    English,
227    Finnish,
228    French,
229    German,
230    Greek,
231    Hungarian,
232    Italian,
233    Norwegian,
234    Portuguese,
235    Romanian,
236    Russian,
237    Spanish,
238    Swedish,
239    Tamil,
240    Turkish,
241}
242
243impl Language {
244    fn to_algorithm(self) -> Algorithm {
245        match self {
246            Language::Arabic => Algorithm::Arabic,
247            Language::Danish => Algorithm::Danish,
248            Language::Dutch => Algorithm::Dutch,
249            Language::English => Algorithm::English,
250            Language::Finnish => Algorithm::Finnish,
251            Language::French => Algorithm::French,
252            Language::German => Algorithm::German,
253            Language::Greek => Algorithm::Greek,
254            Language::Hungarian => Algorithm::Hungarian,
255            Language::Italian => Algorithm::Italian,
256            Language::Norwegian => Algorithm::Norwegian,
257            Language::Portuguese => Algorithm::Portuguese,
258            Language::Romanian => Algorithm::Romanian,
259            Language::Russian => Algorithm::Russian,
260            Language::Spanish => Algorithm::Spanish,
261            Language::Swedish => Algorithm::Swedish,
262            Language::Tamil => Algorithm::Tamil,
263            Language::Turkish => Algorithm::Turkish,
264        }
265    }
266
267    fn to_stop_words_language(self) -> LANGUAGE {
268        match self {
269            Language::Arabic => LANGUAGE::Arabic,
270            Language::Danish => LANGUAGE::Danish,
271            Language::Dutch => LANGUAGE::Dutch,
272            Language::English => LANGUAGE::English,
273            Language::Finnish => LANGUAGE::Finnish,
274            Language::French => LANGUAGE::French,
275            Language::German => LANGUAGE::German,
276            Language::Greek => LANGUAGE::Greek,
277            Language::Hungarian => LANGUAGE::Hungarian,
278            Language::Italian => LANGUAGE::Italian,
279            Language::Norwegian => LANGUAGE::Norwegian,
280            Language::Portuguese => LANGUAGE::Portuguese,
281            Language::Romanian => LANGUAGE::Romanian,
282            Language::Russian => LANGUAGE::Russian,
283            Language::Spanish => LANGUAGE::Spanish,
284            Language::Swedish => LANGUAGE::Swedish,
285            Language::Tamil => LANGUAGE::Tamil,
286            Language::Turkish => LANGUAGE::Turkish,
287        }
288    }
289}
290
291/// Stop word filter tokenizer - wraps another tokenizer and filters out stop words
292///
293/// Uses the stop-words crate for language-specific stop word lists.
294#[derive(Debug, Clone)]
295pub struct StopWordTokenizer<T: Tokenizer> {
296    inner: T,
297    stop_words: HashSet<String>,
298}
299
300use std::collections::HashSet;
301
302impl<T: Tokenizer> StopWordTokenizer<T> {
303    /// Create a new stop word tokenizer wrapping the given tokenizer
304    pub fn new(inner: T, language: Language) -> Self {
305        let stop_words: HashSet<String> = stop_words::get(language.to_stop_words_language())
306            .iter()
307            .map(|s| s.to_string())
308            .collect();
309        Self { inner, stop_words }
310    }
311
312    /// Create with English stop words
313    pub fn english(inner: T) -> Self {
314        Self::new(inner, Language::English)
315    }
316
317    /// Create with custom stop words
318    pub fn with_custom_stop_words(inner: T, stop_words: HashSet<String>) -> Self {
319        Self { inner, stop_words }
320    }
321
322    /// Check if a word is a stop word
323    pub fn is_stop_word(&self, word: &str) -> bool {
324        self.stop_words.contains(word)
325    }
326}
327
328impl<T: Tokenizer> Tokenizer for StopWordTokenizer<T> {
329    fn tokenize(&self, text: &str) -> Vec<Token> {
330        self.inner
331            .tokenize(text)
332            .into_iter()
333            .filter(|token| !self.stop_words.contains(token.text.as_str()))
334            .collect()
335    }
336}
337
338/// Stemming tokenizer - splits on whitespace, lowercases, and applies stemming
339///
340/// Uses the Snowball stemming algorithm via rust-stemmers.
341/// Supports multiple languages including English, German, French, Spanish, etc.
342#[derive(Debug, Clone)]
343pub struct StemmerTokenizer {
344    language: Language,
345}
346
347impl StemmerTokenizer {
348    /// Create a new stemmer tokenizer for the given language
349    pub fn new(language: Language) -> Self {
350        Self { language }
351    }
352
353    /// Create a new English stemmer tokenizer
354    pub fn english() -> Self {
355        Self::new(Language::English)
356    }
357}
358
359impl Default for StemmerTokenizer {
360    fn default() -> Self {
361        Self::english()
362    }
363}
364
365impl Tokenizer for StemmerTokenizer {
366    fn tokenize(&self, text: &str) -> Vec<Token> {
367        let stemmer = rust_stemmers::Stemmer::create(self.language.to_algorithm());
368        tokenize_and_clean(text, |s| stemmer.stem(&s).into_owned())
369    }
370}
371
372/// Multi-language stemmer that can select language dynamically
373///
374/// This tokenizer holds stemmers for multiple languages and can tokenize
375/// text using a specific language selected at runtime.
376#[derive(Debug, Clone)]
377pub struct MultiLanguageStemmer {
378    default_language: Language,
379}
380
381impl MultiLanguageStemmer {
382    /// Create a new multi-language stemmer with the given default language
383    pub fn new(default_language: Language) -> Self {
384        Self { default_language }
385    }
386
387    /// Tokenize text using a specific language
388    pub fn tokenize_with_language(&self, text: &str, language: Language) -> Vec<Token> {
389        let stemmer = rust_stemmers::Stemmer::create(language.to_algorithm());
390        tokenize_and_clean(text, |s| stemmer.stem(&s).into_owned())
391    }
392
393    /// Get the default language
394    pub fn default_language(&self) -> Language {
395        self.default_language
396    }
397}
398
399impl Default for MultiLanguageStemmer {
400    fn default() -> Self {
401        Self::new(Language::English)
402    }
403}
404
405impl Tokenizer for MultiLanguageStemmer {
406    fn tokenize(&self, text: &str) -> Vec<Token> {
407        self.tokenize_with_language(text, self.default_language)
408    }
409}
410
411/// Language-aware tokenizer that can be configured per-field
412///
413/// This allows selecting the stemmer language based on document metadata,
414/// such as a "language" field in the document.
415#[derive(Clone)]
416pub struct LanguageAwareTokenizer<F>
417where
418    F: Fn(&str) -> Language + Clone + Send + Sync + 'static,
419{
420    language_selector: F,
421    stemmer: MultiLanguageStemmer,
422}
423
424impl<F> LanguageAwareTokenizer<F>
425where
426    F: Fn(&str) -> Language + Clone + Send + Sync + 'static,
427{
428    /// Create a new language-aware tokenizer with a custom language selector
429    ///
430    /// The selector function receives a language hint (e.g., from a document field)
431    /// and returns the appropriate Language to use for stemming.
432    ///
433    /// # Example
434    /// ```ignore
435    /// let tokenizer = LanguageAwareTokenizer::new(|hint| {
436    ///     match hint {
437    ///         "en" | "english" => Language::English,
438    ///         "de" | "german" => Language::German,
439    ///         "ru" | "russian" => Language::Russian,
440    ///         _ => Language::English,
441    ///     }
442    /// });
443    /// ```
444    pub fn new(language_selector: F) -> Self {
445        Self {
446            language_selector,
447            stemmer: MultiLanguageStemmer::default(),
448        }
449    }
450
451    /// Tokenize text with a language hint
452    ///
453    /// The hint is passed to the language selector to determine which stemmer to use.
454    pub fn tokenize_with_hint(&self, text: &str, language_hint: &str) -> Vec<Token> {
455        let language = (self.language_selector)(language_hint);
456        self.stemmer.tokenize_with_language(text, language)
457    }
458}
459
460impl<F> Tokenizer for LanguageAwareTokenizer<F>
461where
462    F: Fn(&str) -> Language + Clone + Send + Sync + 'static,
463{
464    fn tokenize(&self, text: &str) -> Vec<Token> {
465        // Default to English when no hint is provided
466        self.stemmer.tokenize_with_language(text, Language::English)
467    }
468
469    fn tokenize_hinted(&self, text: &str, hint: Option<&str>) -> Vec<Token> {
470        match hint {
471            Some(hint) => self.tokenize_with_hint(text, hint),
472            None => Tokenizer::tokenize(self, text),
473        }
474    }
475}
476
477/// Parse a language string into a Language enum
478///
479/// Supports common language codes and names.
480pub fn parse_language(s: &str) -> Language {
481    match s.to_lowercase().as_str() {
482        "ar" | "arabic" => Language::Arabic,
483        "da" | "danish" => Language::Danish,
484        "nl" | "dutch" => Language::Dutch,
485        "en" | "english" => Language::English,
486        "fi" | "finnish" => Language::Finnish,
487        "fr" | "french" => Language::French,
488        "de" | "german" => Language::German,
489        "el" | "greek" => Language::Greek,
490        "hu" | "hungarian" => Language::Hungarian,
491        "it" | "italian" => Language::Italian,
492        "no" | "norwegian" => Language::Norwegian,
493        "pt" | "portuguese" => Language::Portuguese,
494        "ro" | "romanian" => Language::Romanian,
495        "ru" | "russian" => Language::Russian,
496        "es" | "spanish" => Language::Spanish,
497        "sv" | "swedish" => Language::Swedish,
498        "ta" | "tamil" => Language::Tamil,
499        "tr" | "turkish" => Language::Turkish,
500        _ => Language::English, // Default fallback
501    }
502}
503
504/// Parse a language string into a Language, returning `None` for unknown values.
505///
506/// Accepts ISO 639-1 codes and English language names, case-insensitively.
507/// Unlike [`parse_language`], unknown input is not silently mapped to English.
508pub fn parse_language_opt(s: &str) -> Option<Language> {
509    Some(match s.trim().to_lowercase().as_str() {
510        "ar" | "arabic" => Language::Arabic,
511        "da" | "danish" => Language::Danish,
512        "nl" | "dutch" => Language::Dutch,
513        "en" | "english" => Language::English,
514        "fi" | "finnish" => Language::Finnish,
515        "fr" | "french" => Language::French,
516        "de" | "german" => Language::German,
517        "el" | "greek" => Language::Greek,
518        "hu" | "hungarian" => Language::Hungarian,
519        "it" | "italian" => Language::Italian,
520        "no" | "norwegian" => Language::Norwegian,
521        "pt" | "portuguese" => Language::Portuguese,
522        "ro" | "romanian" => Language::Romanian,
523        "ru" | "russian" => Language::Russian,
524        "es" | "spanish" => Language::Spanish,
525        "sv" | "swedish" => Language::Swedish,
526        "ta" | "tamil" => Language::Tamil,
527        "tr" | "turkish" => Language::Turkish,
528        _ => return None,
529    })
530}
531
532/// Writing system of a token, used to route it to a stemmer of the same script.
533#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
534pub enum Script {
535    Latin,
536    Cyrillic,
537    Greek,
538    Arabic,
539    Tamil,
540    /// Any other script (CJK, Hebrew, digits-only tokens, ...)
541    Other,
542}
543
544impl Script {
545    /// Script of the first alphabetic character of `token`; `Other` when none.
546    pub fn of_token(token: &str) -> Script {
547        let Some(c) = token.chars().find(|c| c.is_alphabetic()) else {
548            return Script::Other;
549        };
550        Script::of_char(c)
551    }
552
553    fn of_char(c: char) -> Script {
554        match c as u32 {
555            // Basic Latin, Latin-1 Supplement, Latin Extended-A/B, Latin Extended Additional
556            0x0041..=0x024F | 0x1E00..=0x1EFF => Script::Latin,
557            0x0370..=0x03FF | 0x1F00..=0x1FFF => Script::Greek,
558            0x0400..=0x052F => Script::Cyrillic,
559            0x0600..=0x06FF | 0x0750..=0x077F | 0x08A0..=0x08FF => Script::Arabic,
560            0x0B80..=0x0BFF => Script::Tamil,
561            _ => Script::Other,
562        }
563    }
564}
565
566impl Language {
567    /// Script the Snowball algorithm of this language operates on.
568    pub fn script(self) -> Script {
569        match self {
570            Language::Russian => Script::Cyrillic,
571            Language::Greek => Script::Greek,
572            Language::Arabic => Script::Arabic,
573            Language::Tamil => Script::Tamil,
574            _ => Script::Latin,
575        }
576    }
577}
578
579thread_local! {
580    static STEMMER_CACHE: std::cell::RefCell<HashMap<Language, rust_stemmers::Stemmer>> =
581        std::cell::RefCell::new(HashMap::new());
582}
583
584/// Stem an owned, already cleaned token, reusing its allocation when the
585/// stemmer leaves it unchanged (the common case for short and stop-like
586/// words, and for every token of a script the stemmer does not touch).
587#[inline]
588fn stem_owned(stemmer: &rust_stemmers::Stemmer, word: String) -> String {
589    match stemmer.stem(&word) {
590        std::borrow::Cow::Borrowed(_) => word,
591        std::borrow::Cow::Owned(stemmed) => stemmed,
592    }
593}
594
595/// Run `f` with the cached Snowball stemmers for `languages`, in order.
596///
597/// The thread-local cache is borrowed once per tokenization call instead of
598/// once per token, so the hot loop pays no `RefCell` borrow or hash lookup.
599fn with_stemmers<R>(languages: &[Language], f: impl FnOnce(&[&rust_stemmers::Stemmer]) -> R) -> R {
600    STEMMER_CACHE.with(|cache| {
601        let mut cache = cache.borrow_mut();
602        for language in languages {
603            cache
604                .entry(*language)
605                .or_insert_with(|| rust_stemmers::Stemmer::create(language.to_algorithm()));
606        }
607        let stemmers: Vec<&rust_stemmers::Stemmer> =
608            languages.iter().map(|language| &cache[language]).collect();
609        f(&stemmers)
610    })
611}
612
613/// Word segmentation of a [`DynamicStemmer`].
614#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
615pub enum Segmenter {
616    /// Split on whitespace, strip every non-alphanumeric character
617    /// (`float-zero` → `floatzero`, no CJK segmentation).
618    #[default]
619    Simple,
620    /// UAX #29 word boundaries (`float-zero` → `float`, `zero`; `p53`,
621    /// `co2` and `10.1007` stay whole), character bigrams over runs of Han,
622    /// Hiragana and Katakana, and diacritic folding of Latin, Cyrillic and
623    /// Greek tokens after stemming (`résumé` → `resume`).
624    Unicode,
625}
626
627/// Whether `c` belongs to a script that is bigrammed instead of stemmed.
628#[inline]
629fn is_cjk_char(c: char) -> bool {
630    matches!(
631        c as u32,
632        0x3040..=0x30FF      // Hiragana, Katakana
633            | 0x31F0..=0x31FF // Katakana phonetic extensions
634            | 0x3400..=0x4DBF // CJK extension A
635            | 0x4E00..=0x9FFF // CJK unified ideographs
636            | 0xF900..=0xFAFF // CJK compatibility ideographs
637            | 0xFF66..=0xFF9F // half-width Katakana
638            | 0x20000..=0x2FA1F // CJK extensions B..F, compatibility supplement
639    )
640}
641
642/// Fold diacritics of Latin, Cyrillic and Greek text: compatibility
643/// decomposition, then every combining mark is dropped. Other scripts are
644/// returned unchanged (their combining marks are letters in their own right).
645fn fold_diacritics(word: String) -> String {
646    if word.is_ascii() {
647        return word;
648    }
649    if !matches!(
650        Script::of_token(&word),
651        Script::Latin | Script::Cyrillic | Script::Greek
652    ) {
653        return word;
654    }
655    use unicode_normalization::UnicodeNormalization;
656    use unicode_normalization::char::is_combining_mark;
657    word.nfkd()
658        .filter(|c| !is_combining_mark(*c))
659        .flat_map(|c| c.to_lowercase())
660        .collect()
661}
662
663/// UAX #29 tokenization with CJK bigrams and diacritic folding. `transform`
664/// receives each cleaned (lowercased, punctuation-free) non-CJK word and may
665/// drop it by returning `None`; dropped words still consume a position.
666/// Folding happens after the transform, so stop lists and stemmers see the
667/// original letters.
668fn tokenize_unicode_filtered(
669    text: &str,
670    transform: impl Fn(String) -> Option<String>,
671) -> Vec<Token> {
672    use unicode_segmentation::UnicodeSegmentation;
673
674    let mut tokens = Vec::with_capacity(text.len() / 5);
675    let mut position = 0u32;
676    // Contiguous run of CJK characters: (byte offset, char).
677    let mut run: Vec<(usize, char)> = Vec::new();
678    let mut run_end = 0usize;
679
680    fn flush_run(run: &mut Vec<(usize, char)>, tokens: &mut Vec<Token>, position: &mut u32) {
681        match run.len() {
682            0 => {}
683            1 => {
684                let (offset, c) = run[0];
685                tokens.push(Token::new(
686                    c.to_string(),
687                    *position,
688                    offset,
689                    offset + c.len_utf8(),
690                ));
691                *position += 1;
692            }
693            _ => {
694                for pair in run.windows(2) {
695                    let (start, a) = pair[0];
696                    let (next, b) = pair[1];
697                    let mut text = String::with_capacity(a.len_utf8() + b.len_utf8());
698                    text.push(a);
699                    text.push(b);
700                    tokens.push(Token::new(text, *position, start, next + b.len_utf8()));
701                    *position += 1;
702                }
703            }
704        }
705        run.clear();
706    }
707
708    for (offset, word) in text.unicode_word_indices() {
709        if word.chars().all(is_cjk_char) {
710            // UAX #29 yields one word per ideograph but keeps kana runs
711            // together; either way the characters join the current run when
712            // they are adjacent in the text.
713            if !run.is_empty() && offset != run_end {
714                flush_run(&mut run, &mut tokens, &mut position);
715            }
716            let mut at = offset;
717            for c in word.chars() {
718                run.push((at, c));
719                at += c.len_utf8();
720            }
721            run_end = at;
722            continue;
723        }
724        flush_run(&mut run, &mut tokens, &mut position);
725        let cleaned = clean_word(word);
726        if cleaned.is_empty() {
727            continue;
728        }
729        if let Some(out) = transform(cleaned) {
730            tokens.push(Token::new(
731                fold_diacritics(out),
732                position,
733                offset,
734                offset + word.len(),
735            ));
736        }
737        position += 1;
738    }
739    flush_run(&mut run, &mut tokens, &mut position);
740    tokens
741}
742
743/// Stop words of a language (NLTK lists), shared for the process lifetime.
744fn stop_word_set(language: Language) -> Option<&'static HashSet<String>> {
745    static SETS: std::sync::OnceLock<RwLock<HashMap<Language, &'static HashSet<String>>>> =
746        std::sync::OnceLock::new();
747    let sets = SETS.get_or_init(|| RwLock::new(HashMap::new()));
748    if let Some(set) = sets.read().get(&language) {
749        return Some(set);
750    }
751    let set: &'static HashSet<String> = Box::leak(Box::new(
752        stop_words::get(language.to_stop_words_language())
753            .iter()
754            .map(|word| word.to_string())
755            .collect(),
756    ));
757    Some(*sets.write().entry(language).or_insert(set))
758}
759
760/// Stemmer whose language is selected per call from the tokenizer hint.
761///
762/// The hint is a comma-separated list of language codes or names (`"ru,en"`).
763/// Each token is stemmed with the first hinted language whose script matches
764/// the token's script; tokens of a script no hinted language covers are kept
765/// as cleaned (lowercased, punctuation stripped) text. Without a hint the
766/// `default` language applies, or plain cleaning when it is `None`.
767///
768/// Because Snowball stemmers are script-local, one field can hold mixed
769/// Cyrillic/Latin documents and still stem each part correctly.
770///
771/// With `stop_words` enabled, the stop words of the language a token is
772/// routed to are dropped before stemming. Dropped words keep consuming
773/// positions, so phrase distances are preserved (`"quantum of the art"`
774/// becomes `quantum@0 art@3`).
775///
776/// Declared in SDL as
777/// `text<stem(by: <field>, default: <language|simple>, stop_words: <bool>, segmenter: <simple|unicode>)>`;
778/// see [`TokenizerSpec`].
779#[derive(Debug, Clone, Default)]
780pub struct DynamicStemmer {
781    default: Option<Language>,
782    stop_words: bool,
783    segmenter: Segmenter,
784}
785
786impl DynamicStemmer {
787    /// Create a dynamic stemmer; `default` applies when no hint is present.
788    /// Stop words are kept unless [`DynamicStemmer::with_stop_words`] is set;
789    /// words are split by [`Segmenter::Simple`] unless
790    /// [`DynamicStemmer::with_segmenter`] is set.
791    pub fn new(default: Option<Language>) -> Self {
792        Self {
793            default,
794            stop_words: false,
795            segmenter: Segmenter::Simple,
796        }
797    }
798
799    /// Choose the word segmentation (see [`Segmenter`]).
800    pub fn with_segmenter(mut self, segmenter: Segmenter) -> Self {
801        self.segmenter = segmenter;
802        self
803    }
804
805    /// The word segmentation in use.
806    pub fn segmenter(&self) -> Segmenter {
807        self.segmenter
808    }
809
810    /// Drop the routed language's stop words (positions are preserved).
811    pub fn with_stop_words(mut self, enabled: bool) -> Self {
812        self.stop_words = enabled;
813        self
814    }
815
816    /// Whether stop words are dropped.
817    pub fn strips_stop_words(&self) -> bool {
818        self.stop_words
819    }
820
821    /// Default language used when no hint is given.
822    pub fn default_language(&self) -> Option<Language> {
823        self.default
824    }
825
826    /// Parse a hint into the ordered list of recognised languages.
827    pub fn parse_hint(hint: &str) -> Vec<Language> {
828        let mut languages = Vec::new();
829        for part in hint.split(',') {
830            if let Some(language) = parse_language_opt(part)
831                && !languages.contains(&language)
832            {
833                languages.push(language);
834            }
835        }
836        languages
837    }
838
839    fn segment(&self, text: &str, transform: impl Fn(String) -> Option<String>) -> Vec<Token> {
840        match self.segmenter {
841            Segmenter::Simple => tokenize_and_clean_filtered(text, transform),
842            Segmenter::Unicode => tokenize_unicode_filtered(text, transform),
843        }
844    }
845
846    fn tokenize_with_languages(&self, text: &str, languages: &[Language]) -> Vec<Token> {
847        if languages.is_empty() {
848            return self.segment(text, Some);
849        }
850        // Stop lists follow the same script routing as the stemmers: a token
851        // is checked against (and then stemmed with) the first hinted
852        // language of its script.
853        let stops: Vec<Option<&'static HashSet<String>>> = languages
854            .iter()
855            .map(|language| self.stop_words.then(|| stop_word_set(*language)).flatten())
856            .collect();
857        with_stemmers(languages, |stemmers| {
858            self.segment(text, |s| {
859                let script = Script::of_token(&s);
860                let Some(index) = languages
861                    .iter()
862                    .position(|language| language.script() == script)
863                else {
864                    return Some(s);
865                };
866                if stops[index].is_some_and(|set| set.contains(s.as_str())) {
867                    return None;
868                }
869                Some(stem_owned(stemmers[index], s))
870            })
871        })
872    }
873}
874
875impl Tokenizer for DynamicStemmer {
876    fn tokenize(&self, text: &str) -> Vec<Token> {
877        match self.default {
878            Some(language) => self.tokenize_with_languages(text, &[language]),
879            None => self.segment(text, Some),
880        }
881    }
882
883    fn tokenize_hinted(&self, text: &str, hint: Option<&str>) -> Vec<Token> {
884        match hint.map(str::trim).filter(|hint| !hint.is_empty()) {
885            Some(hint) => {
886                let languages = Self::parse_hint(hint);
887                if languages.is_empty() {
888                    // Hinted but nothing recognised: fall back to the default.
889                    Tokenizer::tokenize(self, text)
890                } else {
891                    self.tokenize_with_languages(text, &languages)
892                }
893            }
894            None => Tokenizer::tokenize(self, text),
895        }
896    }
897}
898
899/// Parsed form of a tokenizer name in the schema.
900///
901/// Plain names refer to a registered tokenizer (`simple`, `en_stem`, ...).
902/// `stem(by: <field>, default: <language|simple>, stop_words: <bool>)`
903/// declares a [`DynamicStemmer`] whose per-document hint is read from
904/// `<field>` in the same document. The canonical string form is stored in
905/// `FieldEntry::tokenizer`, so index metadata needs no new field; specs
906/// without `stop_words` render exactly as before.
907#[derive(Debug, Clone, PartialEq, Eq)]
908pub enum TokenizerSpec {
909    /// A registered tokenizer name.
910    Named(String),
911    /// Dynamic stemmer hinted by another field of the document.
912    DynamicStem {
913        /// Field whose text values supply the language hint.
914        by: String,
915        /// Language applied when the hint field is absent; `None` = simple.
916        default: Option<Language>,
917        /// Drop the routed language's stop words (positions keep their gaps).
918        stop_words: bool,
919        /// Word segmentation; `Simple` is the historic behaviour.
920        segmenter: Segmenter,
921    },
922}
923
924impl TokenizerSpec {
925    /// Parse a tokenizer name or `stem(...)` spec.
926    pub fn parse(spec: &str) -> Result<TokenizerSpec, String> {
927        let spec = spec.trim();
928        let Some(rest) = spec.strip_prefix("stem(") else {
929            if spec.is_empty() || spec.contains(['(', ')', ':', ',']) {
930                return Err(format!("invalid tokenizer spec '{spec}'"));
931            }
932            return Ok(TokenizerSpec::Named(spec.to_string()));
933        };
934        let Some(params) = rest.strip_suffix(')') else {
935            return Err(format!("tokenizer spec '{spec}' is missing ')'"));
936        };
937        let mut by = None;
938        let mut default = None;
939        let mut stop_words = false;
940        let mut segmenter = Segmenter::Simple;
941        for param in params.split(',') {
942            let param = param.trim();
943            if param.is_empty() {
944                continue;
945            }
946            let Some((key, value)) = param.split_once(':') else {
947                return Err(format!(
948                    "tokenizer spec '{spec}': parameter '{param}' must be 'key: value'"
949                ));
950            };
951            let (key, value) = (key.trim(), value.trim());
952            match key {
953                "by" if !value.is_empty() => by = Some(value.to_string()),
954                "by" => return Err(format!("tokenizer spec '{spec}': 'by' needs a field name")),
955                "default" => {
956                    default = match value {
957                        "simple" | "none" => None,
958                        other => Some(parse_language_opt(other).ok_or_else(|| {
959                            format!("tokenizer spec '{spec}': unknown default language '{other}'")
960                        })?),
961                    };
962                }
963                "stop_words" => {
964                    stop_words = match value {
965                        "true" => true,
966                        "false" => false,
967                        other => {
968                            return Err(format!(
969                                "tokenizer spec '{spec}': 'stop_words' must be true or false, got '{other}'"
970                            ));
971                        }
972                    };
973                }
974                "segmenter" => {
975                    segmenter = match value {
976                        "simple" => Segmenter::Simple,
977                        "unicode" => Segmenter::Unicode,
978                        other => {
979                            return Err(format!(
980                                "tokenizer spec '{spec}': 'segmenter' must be simple or unicode, got '{other}'"
981                            ));
982                        }
983                    };
984                }
985                other => {
986                    return Err(format!(
987                        "tokenizer spec '{spec}': unknown parameter '{other}'"
988                    ));
989                }
990            }
991        }
992        let by = by.ok_or_else(|| format!("tokenizer spec '{spec}' requires 'by: <field>'"))?;
993        Ok(TokenizerSpec::DynamicStem {
994            by,
995            default,
996            stop_words,
997            segmenter,
998        })
999    }
1000
1001    /// Field whose values hint the tokenizer, for dynamic specs.
1002    pub fn hint_field(&self) -> Option<&str> {
1003        match self {
1004            TokenizerSpec::Named(_) => None,
1005            TokenizerSpec::DynamicStem { by, .. } => Some(by),
1006        }
1007    }
1008
1009    /// Build the tokenizer described by a dynamic spec.
1010    pub fn dynamic_tokenizer(&self) -> Option<BoxedTokenizer> {
1011        match self {
1012            TokenizerSpec::Named(_) => None,
1013            TokenizerSpec::DynamicStem {
1014                default,
1015                stop_words,
1016                segmenter,
1017                ..
1018            } => Some(Box::new(
1019                DynamicStemmer::new(*default)
1020                    .with_stop_words(*stop_words)
1021                    .with_segmenter(*segmenter),
1022            )),
1023        }
1024    }
1025}
1026
1027impl std::fmt::Display for TokenizerSpec {
1028    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1029        match self {
1030            TokenizerSpec::Named(name) => f.write_str(name),
1031            TokenizerSpec::DynamicStem {
1032                by,
1033                default,
1034                stop_words,
1035                segmenter,
1036            } => {
1037                let default = match default {
1038                    None => "simple".to_string(),
1039                    Some(language) => language_code(*language).to_string(),
1040                };
1041                write!(f, "stem(by: {by}, default: {default}")?;
1042                if *stop_words {
1043                    write!(f, ", stop_words: true")?;
1044                }
1045                if *segmenter == Segmenter::Unicode {
1046                    write!(f, ", segmenter: unicode")?;
1047                }
1048                write!(f, ")")
1049            }
1050        }
1051    }
1052}
1053
1054/// ISO 639-1 code of a stemmer language.
1055pub fn language_code(language: Language) -> &'static str {
1056    match language {
1057        Language::Arabic => "ar",
1058        Language::Danish => "da",
1059        Language::Dutch => "nl",
1060        Language::English => "en",
1061        Language::Finnish => "fi",
1062        Language::French => "fr",
1063        Language::German => "de",
1064        Language::Greek => "el",
1065        Language::Hungarian => "hu",
1066        Language::Italian => "it",
1067        Language::Norwegian => "no",
1068        Language::Portuguese => "pt",
1069        Language::Romanian => "ro",
1070        Language::Russian => "ru",
1071        Language::Spanish => "es",
1072        Language::Swedish => "sv",
1073        Language::Tamil => "ta",
1074        Language::Turkish => "tr",
1075    }
1076}
1077
1078/// Boxed tokenizer for dynamic dispatch
1079pub type BoxedTokenizer = Box<dyn TokenizerClone>;
1080
1081pub trait TokenizerClone: Send + Sync {
1082    fn tokenize(&self, text: &str) -> Vec<Token>;
1083    /// Hinted tokenization; see [`Tokenizer::tokenize_hinted`].
1084    fn tokenize_hinted(&self, text: &str, hint: Option<&str>) -> Vec<Token>;
1085    fn clone_box(&self) -> BoxedTokenizer;
1086}
1087
1088impl<T: Tokenizer> TokenizerClone for T {
1089    fn tokenize(&self, text: &str) -> Vec<Token> {
1090        Tokenizer::tokenize(self, text)
1091    }
1092
1093    fn tokenize_hinted(&self, text: &str, hint: Option<&str>) -> Vec<Token> {
1094        Tokenizer::tokenize_hinted(self, text, hint)
1095    }
1096
1097    fn clone_box(&self) -> BoxedTokenizer {
1098        Box::new(self.clone())
1099    }
1100}
1101
1102impl Clone for BoxedTokenizer {
1103    fn clone(&self) -> Self {
1104        self.clone_box()
1105    }
1106}
1107
1108/// Registry for named tokenizers
1109///
1110/// Allows registering tokenizers by name and retrieving them for use during indexing.
1111/// Pre-registers common tokenizers: "simple", "raw", "raw_ci", "en_stem", etc.
1112#[derive(Clone)]
1113pub struct TokenizerRegistry {
1114    tokenizers: Arc<RwLock<HashMap<String, BoxedTokenizer>>>,
1115    /// Parsed `stem(...)` specs, keyed by their spec string. Query conversion
1116    /// resolves the field tokenizer on every request; parsing the spec each
1117    /// time was measurable at query rates.
1118    dynamic: Arc<RwLock<HashMap<String, BoxedTokenizer>>>,
1119}
1120
1121impl TokenizerRegistry {
1122    /// Create a new tokenizer registry with default tokenizers registered
1123    pub fn new() -> Self {
1124        let registry = Self {
1125            tokenizers: Arc::new(RwLock::new(HashMap::new())),
1126            dynamic: Arc::new(RwLock::new(HashMap::new())),
1127        };
1128        registry.register_defaults();
1129        registry
1130    }
1131
1132    /// Register default tokenizers
1133    fn register_defaults(&self) {
1134        // Basic tokenizers ("default" is the documented alias of "simple")
1135        self.register("simple", SimpleTokenizer);
1136        self.register("default", SimpleTokenizer);
1137        self.register("raw", RawTokenizer);
1138        self.register("raw_ci", RawCiTokenizer);
1139
1140        // English stemmer variants
1141        self.register("en_stem", StemmerTokenizer::new(Language::English));
1142        self.register("english", StemmerTokenizer::new(Language::English));
1143
1144        // Other language stemmers
1145        self.register("ar_stem", StemmerTokenizer::new(Language::Arabic));
1146        self.register("arabic", StemmerTokenizer::new(Language::Arabic));
1147        self.register("da_stem", StemmerTokenizer::new(Language::Danish));
1148        self.register("danish", StemmerTokenizer::new(Language::Danish));
1149        self.register("nl_stem", StemmerTokenizer::new(Language::Dutch));
1150        self.register("dutch", StemmerTokenizer::new(Language::Dutch));
1151        self.register("fi_stem", StemmerTokenizer::new(Language::Finnish));
1152        self.register("finnish", StemmerTokenizer::new(Language::Finnish));
1153        self.register("fr_stem", StemmerTokenizer::new(Language::French));
1154        self.register("french", StemmerTokenizer::new(Language::French));
1155        self.register("de_stem", StemmerTokenizer::new(Language::German));
1156        self.register("german", StemmerTokenizer::new(Language::German));
1157        self.register("el_stem", StemmerTokenizer::new(Language::Greek));
1158        self.register("greek", StemmerTokenizer::new(Language::Greek));
1159        self.register("hu_stem", StemmerTokenizer::new(Language::Hungarian));
1160        self.register("hungarian", StemmerTokenizer::new(Language::Hungarian));
1161        self.register("it_stem", StemmerTokenizer::new(Language::Italian));
1162        self.register("italian", StemmerTokenizer::new(Language::Italian));
1163        self.register("no_stem", StemmerTokenizer::new(Language::Norwegian));
1164        self.register("norwegian", StemmerTokenizer::new(Language::Norwegian));
1165        self.register("pt_stem", StemmerTokenizer::new(Language::Portuguese));
1166        self.register("portuguese", StemmerTokenizer::new(Language::Portuguese));
1167        self.register("ro_stem", StemmerTokenizer::new(Language::Romanian));
1168        self.register("romanian", StemmerTokenizer::new(Language::Romanian));
1169        self.register("ru_stem", StemmerTokenizer::new(Language::Russian));
1170        self.register("russian", StemmerTokenizer::new(Language::Russian));
1171        self.register("es_stem", StemmerTokenizer::new(Language::Spanish));
1172        self.register("spanish", StemmerTokenizer::new(Language::Spanish));
1173        self.register("sv_stem", StemmerTokenizer::new(Language::Swedish));
1174        self.register("swedish", StemmerTokenizer::new(Language::Swedish));
1175        self.register("ta_stem", StemmerTokenizer::new(Language::Tamil));
1176        self.register("tamil", StemmerTokenizer::new(Language::Tamil));
1177        self.register("tr_stem", StemmerTokenizer::new(Language::Turkish));
1178        self.register("turkish", StemmerTokenizer::new(Language::Turkish));
1179
1180        // Stop word filtered tokenizers (lowercase + stop words)
1181        self.register(
1182            "en_stop",
1183            StopWordTokenizer::new(SimpleTokenizer, Language::English),
1184        );
1185        self.register(
1186            "de_stop",
1187            StopWordTokenizer::new(SimpleTokenizer, Language::German),
1188        );
1189        self.register(
1190            "fr_stop",
1191            StopWordTokenizer::new(SimpleTokenizer, Language::French),
1192        );
1193        self.register(
1194            "ru_stop",
1195            StopWordTokenizer::new(SimpleTokenizer, Language::Russian),
1196        );
1197        self.register(
1198            "es_stop",
1199            StopWordTokenizer::new(SimpleTokenizer, Language::Spanish),
1200        );
1201
1202        // Stop word + stemming tokenizers
1203        self.register(
1204            "en_stem_stop",
1205            StopWordTokenizer::new(StemmerTokenizer::new(Language::English), Language::English),
1206        );
1207        self.register(
1208            "de_stem_stop",
1209            StopWordTokenizer::new(StemmerTokenizer::new(Language::German), Language::German),
1210        );
1211        self.register(
1212            "fr_stem_stop",
1213            StopWordTokenizer::new(StemmerTokenizer::new(Language::French), Language::French),
1214        );
1215        self.register(
1216            "ru_stem_stop",
1217            StopWordTokenizer::new(StemmerTokenizer::new(Language::Russian), Language::Russian),
1218        );
1219        self.register(
1220            "es_stem_stop",
1221            StopWordTokenizer::new(StemmerTokenizer::new(Language::Spanish), Language::Spanish),
1222        );
1223    }
1224
1225    /// Register a tokenizer with a name
1226    pub fn register<T: Tokenizer>(&self, name: &str, tokenizer: T) {
1227        let mut tokenizers = self.tokenizers.write();
1228        tokenizers.insert(name.to_string(), Box::new(tokenizer));
1229    }
1230
1231    /// Get a tokenizer by name or by a `stem(by: ..., default: ...)` spec.
1232    ///
1233    /// Dynamic specs are parsed once per distinct spec string and cached; a
1234    /// malformed spec is not cached and yields `None` on every call.
1235    pub fn get(&self, name: &str) -> Option<BoxedTokenizer> {
1236        if name.starts_with("stem(") {
1237            if let Some(tokenizer) = self.dynamic.read().get(name) {
1238                return Some(tokenizer.clone());
1239            }
1240            let tokenizer = TokenizerSpec::parse(name)
1241                .ok()
1242                .and_then(|spec| spec.dynamic_tokenizer())?;
1243            self.dynamic
1244                .write()
1245                .entry(name.to_string())
1246                .or_insert_with(|| tokenizer.clone());
1247            return Some(tokenizer);
1248        }
1249        let tokenizers = self.tokenizers.read();
1250        tokenizers.get(name).cloned()
1251    }
1252
1253    /// Check if a tokenizer is registered
1254    pub fn contains(&self, name: &str) -> bool {
1255        let tokenizers = self.tokenizers.read();
1256        tokenizers.contains_key(name)
1257    }
1258
1259    /// List all registered tokenizer names
1260    pub fn names(&self) -> Vec<String> {
1261        let tokenizers = self.tokenizers.read();
1262        tokenizers.keys().cloned().collect()
1263    }
1264}
1265
1266impl Default for TokenizerRegistry {
1267    fn default() -> Self {
1268        Self::new()
1269    }
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274    use super::*;
1275
1276    #[test]
1277    fn test_simple_tokenizer() {
1278        let tokenizer = SimpleTokenizer;
1279        let tokens = Tokenizer::tokenize(&tokenizer, "Hello World");
1280
1281        assert_eq!(tokens.len(), 2);
1282        assert_eq!(tokens[0].text, "hello");
1283        assert_eq!(tokens[0].position, 0);
1284        assert_eq!(tokens[1].text, "world");
1285        assert_eq!(tokens[1].position, 1);
1286    }
1287
1288    #[test]
1289    fn test_raw_tokenizer() {
1290        let tokenizer = RawTokenizer;
1291        // Entire input becomes one token, preserving case and punctuation
1292        let tokens = Tokenizer::tokenize(&tokenizer, "Hello, World!");
1293        assert_eq!(tokens.len(), 1);
1294        assert_eq!(tokens[0].text, "Hello, World!");
1295        assert_eq!(tokens[0].position, 0);
1296    }
1297
1298    #[test]
1299    fn test_raw_tokenizer_trims() {
1300        let tokenizer = RawTokenizer;
1301        let tokens = Tokenizer::tokenize(&tokenizer, "  spaced  ");
1302        assert_eq!(tokens.len(), 1);
1303        assert_eq!(tokens[0].text, "spaced");
1304        assert_eq!(tokens[0].offset_from, 2);
1305    }
1306
1307    #[test]
1308    fn test_raw_tokenizer_empty() {
1309        let tokenizer = RawTokenizer;
1310        assert!(Tokenizer::tokenize(&tokenizer, "").is_empty());
1311        assert!(Tokenizer::tokenize(&tokenizer, "   ").is_empty());
1312    }
1313
1314    #[test]
1315    fn test_raw_ci_tokenizer() {
1316        let tokenizer = RawCiTokenizer;
1317        // Entire input lowercased as one token
1318        let tokens = Tokenizer::tokenize(&tokenizer, "Hello, World!");
1319        assert_eq!(tokens.len(), 1);
1320        assert_eq!(tokens[0].text, "hello, world!");
1321        assert_eq!(tokens[0].position, 0);
1322    }
1323
1324    #[test]
1325    fn test_raw_ci_tokenizer_preserves_structure() {
1326        let tokenizer = RawCiTokenizer;
1327        let tokens = Tokenizer::tokenize(&tokenizer, "HTTPS://Example.COM/Page");
1328        assert_eq!(tokens.len(), 1);
1329        assert_eq!(tokens[0].text, "https://example.com/page");
1330    }
1331
1332    #[test]
1333    fn test_simple_tokenizer_strips_punctuation() {
1334        let tokenizer = SimpleTokenizer;
1335        let tokens = Tokenizer::tokenize(&tokenizer, "Hello, World!");
1336
1337        assert_eq!(tokens.len(), 2);
1338        assert_eq!(tokens[0].text, "hello");
1339        assert_eq!(tokens[1].text, "world");
1340    }
1341
1342    #[test]
1343    fn test_empty_text() {
1344        let tokenizer = SimpleTokenizer;
1345        let tokens = Tokenizer::tokenize(&tokenizer, "");
1346        assert!(tokens.is_empty());
1347    }
1348
1349    #[test]
1350    fn test_stemmer_tokenizer_english() {
1351        let tokenizer = StemmerTokenizer::english();
1352        let tokens = Tokenizer::tokenize(&tokenizer, "Dogs are running quickly");
1353
1354        assert_eq!(tokens.len(), 4);
1355        assert_eq!(tokens[0].text, "dog"); // dogs -> dog
1356        assert_eq!(tokens[1].text, "are"); // are -> are
1357        assert_eq!(tokens[2].text, "run"); // running -> run
1358        assert_eq!(tokens[3].text, "quick"); // quickly -> quick
1359    }
1360
1361    #[test]
1362    fn test_stemmer_tokenizer_preserves_offsets() {
1363        let tokenizer = StemmerTokenizer::english();
1364        let tokens = Tokenizer::tokenize(&tokenizer, "Running dogs");
1365
1366        assert_eq!(tokens.len(), 2);
1367        assert_eq!(tokens[0].text, "run");
1368        assert_eq!(tokens[0].offset_from, 0);
1369        assert_eq!(tokens[0].offset_to, 7); // "Running" is 7 chars
1370        assert_eq!(tokens[1].text, "dog");
1371        assert_eq!(tokens[1].offset_from, 8);
1372        assert_eq!(tokens[1].offset_to, 12); // "dogs" is 4 chars
1373    }
1374
1375    #[test]
1376    fn test_stemmer_tokenizer_german() {
1377        let tokenizer = StemmerTokenizer::new(Language::German);
1378        let tokens = Tokenizer::tokenize(&tokenizer, "Häuser Bücher");
1379
1380        assert_eq!(tokens.len(), 2);
1381        // German stemmer should stem these plural forms
1382        assert_eq!(tokens[0].text, "haus"); // häuser -> haus
1383        assert_eq!(tokens[1].text, "buch"); // bücher -> buch
1384    }
1385
1386    #[test]
1387    fn test_stemmer_tokenizer_russian() {
1388        let tokenizer = StemmerTokenizer::new(Language::Russian);
1389        let tokens = Tokenizer::tokenize(&tokenizer, "бегущие собаки");
1390
1391        assert_eq!(tokens.len(), 2);
1392        // Russian stemmer should stem these
1393        assert_eq!(tokens[0].text, "бегущ"); // бегущие -> бегущ
1394        assert_eq!(tokens[1].text, "собак"); // собаки -> собак
1395    }
1396
1397    #[test]
1398    fn test_multi_language_stemmer() {
1399        let stemmer = MultiLanguageStemmer::new(Language::English);
1400
1401        // Test with English
1402        let tokens = stemmer.tokenize_with_language("running dogs", Language::English);
1403        assert_eq!(tokens[0].text, "run");
1404        assert_eq!(tokens[1].text, "dog");
1405
1406        // Test with German
1407        let tokens = stemmer.tokenize_with_language("Häuser Bücher", Language::German);
1408        assert_eq!(tokens[0].text, "haus");
1409        assert_eq!(tokens[1].text, "buch");
1410
1411        // Test with Russian
1412        let tokens = stemmer.tokenize_with_language("бегущие собаки", Language::Russian);
1413        assert_eq!(tokens[0].text, "бегущ");
1414        assert_eq!(tokens[1].text, "собак");
1415    }
1416
1417    #[test]
1418    fn test_language_aware_tokenizer() {
1419        let tokenizer = LanguageAwareTokenizer::new(parse_language);
1420
1421        // English hint
1422        let tokens = tokenizer.tokenize_with_hint("running dogs", "en");
1423        assert_eq!(tokens[0].text, "run");
1424        assert_eq!(tokens[1].text, "dog");
1425
1426        // German hint
1427        let tokens = tokenizer.tokenize_with_hint("Häuser Bücher", "de");
1428        assert_eq!(tokens[0].text, "haus");
1429        assert_eq!(tokens[1].text, "buch");
1430
1431        // Russian hint
1432        let tokens = tokenizer.tokenize_with_hint("бегущие собаки", "russian");
1433        assert_eq!(tokens[0].text, "бегущ");
1434        assert_eq!(tokens[1].text, "собак");
1435    }
1436
1437    #[test]
1438    fn test_parse_language() {
1439        assert_eq!(parse_language("en"), Language::English);
1440        assert_eq!(parse_language("english"), Language::English);
1441        assert_eq!(parse_language("English"), Language::English);
1442        assert_eq!(parse_language("de"), Language::German);
1443        assert_eq!(parse_language("german"), Language::German);
1444        assert_eq!(parse_language("ru"), Language::Russian);
1445        assert_eq!(parse_language("russian"), Language::Russian);
1446        assert_eq!(parse_language("unknown"), Language::English); // fallback
1447    }
1448
1449    #[test]
1450    fn test_tokenizer_registry_defaults() {
1451        let registry = TokenizerRegistry::new();
1452
1453        // Check default tokenizers are registered
1454        assert!(registry.contains("simple"));
1455        assert!(registry.contains("raw"));
1456        assert!(registry.contains("raw_ci"));
1457        assert!(registry.contains("raw"));
1458        assert!(registry.contains("raw_ci"));
1459        assert!(registry.contains("en_stem"));
1460        assert!(registry.contains("german"));
1461        assert!(registry.contains("russian"));
1462    }
1463
1464    #[test]
1465    fn test_tokenizer_registry_get() {
1466        let registry = TokenizerRegistry::new();
1467
1468        // Get and use a tokenizer
1469        let tokenizer = registry.get("en_stem").unwrap();
1470        let tokens = tokenizer.tokenize("running dogs");
1471        assert_eq!(tokens[0].text, "run");
1472        assert_eq!(tokens[1].text, "dog");
1473
1474        // Get German stemmer
1475        let tokenizer = registry.get("german").unwrap();
1476        let tokens = tokenizer.tokenize("Häuser Bücher");
1477        assert_eq!(tokens[0].text, "haus");
1478        assert_eq!(tokens[1].text, "buch");
1479    }
1480
1481    #[test]
1482    fn test_tokenizer_registry_custom() {
1483        let registry = TokenizerRegistry::new();
1484
1485        // Register a custom tokenizer
1486        registry.register("my_tokenizer", SimpleTokenizer);
1487
1488        assert!(registry.contains("my_tokenizer"));
1489        let tokenizer = registry.get("my_tokenizer").unwrap();
1490        let tokens = tokenizer.tokenize("Hello World");
1491        assert_eq!(tokens[0].text, "hello");
1492        assert_eq!(tokens[1].text, "world");
1493    }
1494
1495    #[test]
1496    fn test_tokenizer_registry_nonexistent() {
1497        let registry = TokenizerRegistry::new();
1498        assert!(registry.get("nonexistent").is_none());
1499    }
1500
1501    #[test]
1502    fn test_stop_word_tokenizer_english() {
1503        let tokenizer = StopWordTokenizer::english(SimpleTokenizer);
1504        let tokens = Tokenizer::tokenize(&tokenizer, "The quick brown fox jumps over the lazy dog");
1505
1506        // "the", "over" are stop words and should be filtered
1507        let texts: Vec<&str> = tokens.iter().map(|t| t.text.as_str()).collect();
1508        assert!(!texts.contains(&"the"));
1509        assert!(!texts.contains(&"over"));
1510        assert!(texts.contains(&"quick"));
1511        assert!(texts.contains(&"brown"));
1512        assert!(texts.contains(&"fox"));
1513        assert!(texts.contains(&"jumps"));
1514        assert!(texts.contains(&"lazy"));
1515        assert!(texts.contains(&"dog"));
1516    }
1517
1518    #[test]
1519    fn test_stop_word_tokenizer_with_stemmer() {
1520        // Note: StopWordTokenizer filters AFTER stemming, so stop words
1521        // that get stemmed may not be filtered. For proper stop word + stemming,
1522        // filter stop words before stemming or use a stemmed stop word list.
1523        let tokenizer = StopWordTokenizer::new(StemmerTokenizer::english(), Language::English);
1524        let tokens = Tokenizer::tokenize(&tokenizer, "elephants galaxies quantum");
1525
1526        let texts: Vec<&str> = tokens.iter().map(|t| t.text.as_str()).collect();
1527        // Stemmed forms should be present (these are not stop words)
1528        assert!(texts.contains(&"eleph")); // elephants -> eleph
1529        assert!(texts.contains(&"galaxi")); // galaxies -> galaxi
1530        assert!(texts.contains(&"quantum")); // quantum -> quantum
1531    }
1532
1533    #[test]
1534    fn test_stop_word_tokenizer_german() {
1535        let tokenizer = StopWordTokenizer::new(SimpleTokenizer, Language::German);
1536        let tokens = Tokenizer::tokenize(&tokenizer, "Der Hund und die Katze");
1537
1538        // "der", "und", "die" are German stop words
1539        let texts: Vec<&str> = tokens.iter().map(|t| t.text.as_str()).collect();
1540        assert!(!texts.contains(&"der"));
1541        assert!(!texts.contains(&"und"));
1542        assert!(!texts.contains(&"die"));
1543        assert!(texts.contains(&"hund"));
1544        assert!(texts.contains(&"katze"));
1545    }
1546
1547    #[test]
1548    fn test_stop_word_tokenizer_custom() {
1549        let custom_stops: HashSet<String> = ["foo", "bar"].iter().map(|s| s.to_string()).collect();
1550        let tokenizer = StopWordTokenizer::with_custom_stop_words(SimpleTokenizer, custom_stops);
1551        let tokens = Tokenizer::tokenize(&tokenizer, "foo baz bar qux");
1552
1553        let texts: Vec<&str> = tokens.iter().map(|t| t.text.as_str()).collect();
1554        assert!(!texts.contains(&"foo"));
1555        assert!(!texts.contains(&"bar"));
1556        assert!(texts.contains(&"baz"));
1557        assert!(texts.contains(&"qux"));
1558    }
1559
1560    #[test]
1561    fn test_stop_word_tokenizer_is_stop_word() {
1562        let tokenizer = StopWordTokenizer::english(SimpleTokenizer);
1563        assert!(tokenizer.is_stop_word("the"));
1564        assert!(tokenizer.is_stop_word("and"));
1565        assert!(tokenizer.is_stop_word("is"));
1566        // These are definitely not stop words
1567        assert!(!tokenizer.is_stop_word("elephant"));
1568        assert!(!tokenizer.is_stop_word("quantum"));
1569    }
1570
1571    #[test]
1572    fn test_tokenizer_registry_stop_word_tokenizers() {
1573        let registry = TokenizerRegistry::new();
1574
1575        // Check stop word tokenizers are registered
1576        assert!(registry.contains("en_stop"));
1577        assert!(registry.contains("en_stem_stop"));
1578        assert!(registry.contains("de_stop"));
1579        assert!(registry.contains("ru_stop"));
1580
1581        // Test en_stop filters stop words
1582        let tokenizer = registry.get("en_stop").unwrap();
1583        let tokens = tokenizer.tokenize("The quick fox");
1584        let texts: Vec<&str> = tokens.iter().map(|t| t.text.as_str()).collect();
1585        assert!(!texts.contains(&"the"));
1586        assert!(texts.contains(&"quick"));
1587        assert!(texts.contains(&"fox"));
1588
1589        // Test en_stem_stop filters stop words AND stems
1590        let tokenizer = registry.get("en_stem_stop").unwrap();
1591        let tokens = tokenizer.tokenize("elephants galaxies");
1592        let texts: Vec<&str> = tokens.iter().map(|t| t.text.as_str()).collect();
1593        assert!(texts.contains(&"eleph")); // stemmed
1594        assert!(texts.contains(&"galaxi")); // stemmed
1595    }
1596
1597    fn hinted<T: Tokenizer>(tokenizer: &T, text: &str, hint: Option<&str>) -> Vec<Token> {
1598        Tokenizer::tokenize_hinted(tokenizer, text, hint)
1599    }
1600
1601    fn texts(tokens: &[Token]) -> Vec<&str> {
1602        tokens.iter().map(|t| t.text.as_str()).collect()
1603    }
1604
1605    fn positions(tokens: &[Token]) -> Vec<u32> {
1606        tokens.iter().map(|t| t.position).collect()
1607    }
1608
1609    #[test]
1610    fn unicode_segmenter_splits_on_word_boundaries_and_folds() {
1611        let plain = DynamicStemmer::new(None).with_segmenter(Segmenter::Unicode);
1612        let tokens = hinted(
1613            &plain,
1614            "Float-zero determinants: p53/CO2, 10.1007/s1 résumé",
1615            None,
1616        );
1617        assert_eq!(
1618            texts(&tokens),
1619            vec![
1620                "float",
1621                "zero",
1622                "determinants",
1623                "p53",
1624                "co2",
1625                "101007",
1626                "s1",
1627                "resume"
1628            ]
1629        );
1630        assert_eq!(positions(&tokens), (0..8).collect::<Vec<u32>>());
1631        // Offsets still point at the original words.
1632        assert_eq!(
1633            &"Float-zero determinants: p53/CO2, 10.1007/s1 résumé"
1634                [tokens[7].offset_from..tokens[7].offset_to],
1635            "résumé"
1636        );
1637
1638        // Stemming sees the original letters, folding runs afterwards; the
1639        // simple segmenter keeps the historic behaviour.
1640        let english = DynamicStemmer::new(None).with_segmenter(Segmenter::Unicode);
1641        assert_eq!(
1642            texts(&hinted(&english, "Running foxes' café", Some("en"))),
1643            vec!["run", "fox", "cafe"]
1644        );
1645        assert_eq!(
1646            texts(&hinted(
1647                &DynamicStemmer::new(None),
1648                "Float-zero café",
1649                Some("en")
1650            )),
1651            vec!["floatzero", "café"]
1652        );
1653        // Cyrillic folding drops combining marks but keeps the letters.
1654        assert_eq!(texts(&hinted(&plain, "ёлка", None)), vec!["елка"]);
1655        // Stop words are removed before folding and keep their gap.
1656        let stopping = DynamicStemmer::new(None)
1657            .with_stop_words(true)
1658            .with_segmenter(Segmenter::Unicode);
1659        let tokens = hinted(&stopping, "state-of-the-art résumé", Some("en"));
1660        assert_eq!(tokens.len(), 3, "{:?}", texts(&tokens));
1661        assert_eq!(&texts(&tokens)[..2], ["state", "art"]);
1662        assert!(tokens[2].text.starts_with("resum"), "{:?}", tokens[2].text);
1663        assert!(tokens[2].text.is_ascii(), "folded after stemming");
1664        assert_eq!(positions(&tokens), vec![0, 3, 4]);
1665    }
1666
1667    #[test]
1668    fn unicode_segmenter_bigrams_cjk_runs() {
1669        let t = DynamicStemmer::new(None).with_segmenter(Segmenter::Unicode);
1670        let tokens = hinted(&t, "東京都 tower", Some("en"));
1671        assert_eq!(texts(&tokens), vec!["東京", "京都", "tower"]);
1672        assert_eq!(positions(&tokens), vec![0, 1, 2]);
1673        assert_eq!(
1674            &"東京都 tower"[tokens[1].offset_from..tokens[1].offset_to],
1675            "京都"
1676        );
1677        // A lone ideograph is its own token; runs split at any non-CJK word.
1678        assert_eq!(
1679            texts(&hinted(&t, "東 tower 京都", None)),
1680            vec!["東", "tower", "京都"]
1681        );
1682        // Katakana runs are bigrammed too, and a run stops where the text
1683        // stops being contiguous.
1684        assert_eq!(
1685            texts(&hinted(&t, "トウキョウ タワー", None)),
1686            vec!["トウ", "ウキ", "キョ", "ョウ", "タワ", "ワー"]
1687        );
1688        // Mixed script without spaces: the ideographs form one run, the
1689        // Latin word follows.
1690        assert_eq!(texts(&hinted(&t, "東京tower", None)), vec!["東京", "tower"]);
1691        // Phrase offsets follow the bigram positions.
1692        let query = hinted(&t, "東京都", None);
1693        assert_eq!(positions(&query), vec![0, 1]);
1694    }
1695
1696    #[test]
1697    fn dynamic_stemmer_drops_stop_words_but_keeps_positions() {
1698        let stemmer = DynamicStemmer::new(None).with_stop_words(true);
1699        let tokens = hinted(&stemmer, "Quantum of the Art", Some("en"));
1700        assert_eq!(texts(&tokens), vec!["quantum", "art"]);
1701        assert_eq!(positions(&tokens), vec![0, 3]);
1702        // Byte offsets still point at the surviving words.
1703        assert_eq!(tokens[1].offset_from, "Quantum of the ".len());
1704
1705        // Stop words are matched before stemming, per script-routed language.
1706        let tokens = hinted(&stemmer, "бегущие и собаки the foxes", Some("ru,en"));
1707        assert_eq!(texts(&tokens), vec!["бегущ", "собак", "fox"]);
1708        assert_eq!(positions(&tokens), vec![0, 2, 4]);
1709
1710        // Tokens of a script no hinted language covers are untouched.
1711        assert_eq!(
1712            texts(&hinted(&stemmer, "the 日本語 fox", Some("en"))),
1713            vec!["日本語", "fox"]
1714        );
1715
1716        // No hint and no default: nothing is stemmed and nothing is dropped.
1717        assert_eq!(
1718            texts(&hinted(&stemmer, "the fox", None)),
1719            vec!["the", "fox"]
1720        );
1721        // A default language applies its stop list too.
1722        let english = DynamicStemmer::new(Some(Language::English)).with_stop_words(true);
1723        assert_eq!(texts(&hinted(&english, "the fox", None)), vec!["fox"]);
1724        // Off by default.
1725        assert_eq!(
1726            texts(&hinted(&DynamicStemmer::new(None), "the fox", Some("en"))),
1727            vec!["the", "fox"]
1728        );
1729        // Only stop words: no tokens at all.
1730        assert!(hinted(&stemmer, "to be or not to be", Some("en")).is_empty());
1731    }
1732
1733    #[test]
1734    fn dynamic_stemmer_selects_language_from_hint() {
1735        let stemmer = DynamicStemmer::new(None);
1736        assert_eq!(
1737            texts(&hinted(&stemmer, "Running Foxes", Some("en"))),
1738            vec!["run", "fox"]
1739        );
1740        assert_eq!(
1741            texts(&hinted(&stemmer, "бегущие собаки", Some("ru"))),
1742            vec!["бегущ", "собак"]
1743        );
1744        // Unknown hint and no hint both fall back to the default (simple).
1745        assert_eq!(
1746            texts(&hinted(&stemmer, "Running Foxes", Some("xx"))),
1747            vec!["running", "foxes"]
1748        );
1749        assert_eq!(
1750            texts(&hinted(&stemmer, "Running Foxes", None)),
1751            vec!["running", "foxes"]
1752        );
1753        assert_eq!(
1754            texts(&Tokenizer::tokenize(&stemmer, "Running, Foxes!")),
1755            vec!["running", "foxes"]
1756        );
1757        // A default language applies when no hint is given.
1758        let english = DynamicStemmer::new(Some(Language::English));
1759        assert_eq!(
1760            texts(&hinted(&english, "Running Foxes", None)),
1761            vec!["run", "fox"]
1762        );
1763    }
1764
1765    #[test]
1766    fn dynamic_stemmer_routes_tokens_by_script() {
1767        let stemmer = DynamicStemmer::new(None);
1768        // Mixed-script text: each token goes to the hinted language of its script.
1769        assert_eq!(
1770            texts(&hinted(&stemmer, "бегущие foxes", Some("ru,en"))),
1771            vec!["бегущ", "fox"]
1772        );
1773        assert_eq!(
1774            texts(&hinted(&stemmer, "бегущие foxes", Some("en, ru"))),
1775            vec!["бегущ", "fox"]
1776        );
1777        // A single hinted language never touches tokens of another script.
1778        assert_eq!(
1779            texts(&hinted(&stemmer, "бегущие foxes", Some("ru"))),
1780            vec!["бегущ", "foxes"]
1781        );
1782        assert_eq!(
1783            texts(&hinted(&stemmer, "бегущие foxes", Some("en"))),
1784            vec!["бегущие", "fox"]
1785        );
1786        // Same-script languages: the first listed one wins.
1787        assert_eq!(
1788            texts(&hinted(&stemmer, "running", Some("de,en"))),
1789            vec!["running"]
1790        );
1791        assert_eq!(
1792            texts(&hinted(&stemmer, "running", Some("en,de"))),
1793            vec!["run"]
1794        );
1795        // Positions stay sequential across scripts.
1796        let tokens = hinted(&stemmer, "бегущие foxes run", Some("ru,en"));
1797        assert_eq!(
1798            tokens.iter().map(|t| t.position).collect::<Vec<_>>(),
1799            vec![0, 1, 2]
1800        );
1801    }
1802
1803    #[test]
1804    fn script_detection_covers_supported_stemmer_scripts() {
1805        assert_eq!(Script::of_token("hello"), Script::Latin);
1806        assert_eq!(Script::of_token("straße"), Script::Latin);
1807        assert_eq!(Script::of_token("собака"), Script::Cyrillic);
1808        assert_eq!(Script::of_token("γεια"), Script::Greek);
1809        assert_eq!(Script::of_token("مرحبا"), Script::Arabic);
1810        assert_eq!(Script::of_token("தமிழ்"), Script::Tamil);
1811        assert_eq!(Script::of_token("日本語"), Script::Other);
1812        assert_eq!(Script::of_token("2024"), Script::Other);
1813        assert_eq!(Language::Russian.script(), Script::Cyrillic);
1814        assert_eq!(Language::Turkish.script(), Script::Latin);
1815    }
1816
1817    #[test]
1818    fn tokenizer_spec_parses_and_renders_canonically() {
1819        assert_eq!(
1820            TokenizerSpec::parse("en_stem").unwrap(),
1821            TokenizerSpec::Named("en_stem".to_string())
1822        );
1823        let spec = TokenizerSpec::parse("stem(by:languages,default:simple)").unwrap();
1824        assert_eq!(
1825            spec,
1826            TokenizerSpec::DynamicStem {
1827                by: "languages".to_string(),
1828                default: None,
1829                stop_words: false,
1830                segmenter: Segmenter::Simple,
1831            }
1832        );
1833        assert_eq!(spec.to_string(), "stem(by: languages, default: simple)");
1834        assert_eq!(spec.hint_field(), Some("languages"));
1835
1836        let spec = TokenizerSpec::parse("stem(by: lang, default: english)").unwrap();
1837        assert_eq!(spec.to_string(), "stem(by: lang, default: en)");
1838        assert_eq!(
1839            TokenizerSpec::parse("stem(by: lang)").unwrap(),
1840            TokenizerSpec::DynamicStem {
1841                by: "lang".to_string(),
1842                default: None,
1843                stop_words: false,
1844                segmenter: Segmenter::Simple,
1845            }
1846        );
1847
1848        let spec =
1849            TokenizerSpec::parse("stem(by: languages, default: simple, stop_words: true)").unwrap();
1850        assert_eq!(
1851            spec,
1852            TokenizerSpec::DynamicStem {
1853                by: "languages".to_string(),
1854                default: None,
1855                stop_words: true,
1856                segmenter: Segmenter::Simple,
1857            }
1858        );
1859        assert_eq!(
1860            spec.to_string(),
1861            "stem(by: languages, default: simple, stop_words: true)"
1862        );
1863        // `stop_words: false` is the default and renders as the historic form.
1864        assert_eq!(
1865            TokenizerSpec::parse("stem(by: lang, stop_words: false)")
1866                .unwrap()
1867                .to_string(),
1868            "stem(by: lang, default: simple)"
1869        );
1870        assert!(TokenizerSpec::parse("stem(by: lang, stop_words: maybe)").is_err());
1871        let spec =
1872            TokenizerSpec::parse("stem(by: languages, stop_words: true, segmenter: unicode)")
1873                .unwrap();
1874        assert_eq!(
1875            spec,
1876            TokenizerSpec::DynamicStem {
1877                by: "languages".to_string(),
1878                default: None,
1879                stop_words: true,
1880                segmenter: Segmenter::Unicode,
1881            }
1882        );
1883        assert_eq!(
1884            spec.to_string(),
1885            "stem(by: languages, default: simple, stop_words: true, segmenter: unicode)"
1886        );
1887        assert!(TokenizerSpec::parse("stem(by: lang, segmenter: icu)").is_err());
1888
1889        assert!(TokenizerSpec::parse("stem(default: en)").is_err());
1890        assert!(TokenizerSpec::parse("stem(by: lang, default: klingon)").is_err());
1891        assert!(TokenizerSpec::parse("stem(by: lang").is_err());
1892        assert!(TokenizerSpec::parse("stem(by: lang, color: red)").is_err());
1893        assert!(TokenizerSpec::parse("en_stem(foo)").is_err());
1894        assert!(TokenizerSpec::parse("").is_err());
1895    }
1896
1897    #[test]
1898    fn registry_builds_dynamic_stemmer_from_spec() {
1899        let registry = TokenizerRegistry::new();
1900        let tokenizer = registry
1901            .get("stem(by: languages, default: simple)")
1902            .expect("dynamic spec resolves without registration");
1903        assert_eq!(
1904            texts(&tokenizer.tokenize_hinted("Running Foxes", Some("en"))),
1905            vec!["run", "fox"]
1906        );
1907        assert_eq!(
1908            texts(&tokenizer.tokenize("Running Foxes")),
1909            vec!["running", "foxes"]
1910        );
1911        assert!(
1912            registry
1913                .get("stem(by: languages, default: klingon)")
1914                .is_none()
1915        );
1916        let stopping = registry
1917            .get("stem(by: languages, default: simple, stop_words: true)")
1918            .expect("stop-word spec resolves");
1919        let tokens = stopping.tokenize_hinted("the running foxes", Some("en"));
1920        assert_eq!(texts(&tokens), vec!["run", "fox"]);
1921        assert_eq!(positions(&tokens), vec![1, 2]);
1922        // Static tokenizers accept and ignore hints.
1923        let simple = registry.get("en_stem").unwrap();
1924        assert_eq!(
1925            texts(&simple.tokenize_hinted("Running Foxes", Some("ru"))),
1926            vec!["run", "fox"]
1927        );
1928    }
1929
1930    #[test]
1931    fn parse_language_opt_rejects_unknown_codes() {
1932        assert_eq!(parse_language_opt(" RU "), Some(Language::Russian));
1933        assert_eq!(parse_language_opt("german"), Some(Language::German));
1934        assert_eq!(parse_language_opt("xx"), None);
1935        assert_eq!(parse_language_opt(""), None);
1936        for language in [Language::English, Language::Russian, Language::Tamil] {
1937            assert_eq!(parse_language_opt(language_code(language)), Some(language));
1938        }
1939    }
1940}