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