Skip to main content

hermes_core/tokenizer/
lex.rs

1//! The lexical text tokenizer: segmentation, normalisation, morphology and
2//! same-position variants, declared in SDL as
3//!
4//! ```text
5//! text<lex(by: <field>, default: <language|none>, stop_words: <bool>,
6//!          segmenter: <icu|unicode|simple>, stem: <light|snowball|none>,
7//!          variants: <bool>, fold: <bool>, max_token_length: <n>,
8//!          han: <as_written|simplified>, cjk: <icu|dictionary>)>
9//! ```
10//!
11//! Every option has a default (see [`LexOptions`]) and only non-defaults are
12//! rendered, so `lex()` alone is the recommended tokenizer for a
13//! language-agnostic text field and
14//! `lex(by: languages, default: en, stop_words: true)` the recommended one
15//! for a multilingual corpus that tags its documents.
16//!
17//! Three layers, identical at index and query time:
18//!
19//! 1. **Segmentation and normalisation** (language-agnostic). `icu` uses
20//!    ICU4X's word segmenter (dictionary words for Chinese and Japanese, LSTM
21//!    word breaks for Thai, Lao, Khmer and Burmese, UAX #29 elsewhere) and
22//!    emits the bigrams of every dictionary word as same-position variants,
23//!    so a differently segmented query still matches; runs the dictionary
24//!    does not know fall back to character bigrams. `unicode` is UAX #29 with
25//!    bigrams over every CJK run; `simple` splits on whitespace. Every token
26//!    is NFKC-normalised and lowercased; Arabic tokens get the Lucene
27//!    orthographic normalisation, Cyrillic `ё` becomes `е`; `han:
28//!    simplified` folds traditional Chinese characters. Tokens longer than
29//!    `max_token_length` characters are dropped (position kept). `cjk:
30//!    dictionary` adds Japanese and Korean morphology (see
31//!    `cjk_morph`).
32//! 2. **Morphology** per language, routed by the token's script to the first
33//!    hinted language of that script (`by` reads the hint from a sibling
34//!    field at index time, the query passes `tokenizer_hint`; without `by`
35//!    the `default` applies to everything and hints are ignored): `light`
36//!    strips inflection only (see [`super::light_stem`]), `snowball` is the
37//!    full algorithm, `none` keeps the word.
38//! 3. **Variants** (`variants: true`): the written word is the indexed
39//!    token; its stem and its diacritic-folded form (`fold`) are variants at
40//!    the same position, so phrases and exact terms match the written form
41//!    while match queries use the stem. Variants are marked
42//!    [`super::Token::variant`] and do not count towards field length.
43//!    Without variants the (folded) stem replaces the word.
44//!
45//! Query tokenization ([`super::Purpose::Match`], [`super::Purpose::Exact`])
46//! emits one form per word and never a variant: the stem for a match query
47//! (the written form when the language is unknown), the written form for a
48//! phrase or an exact term. Because a stem shares its word's position, a
49//! phrase term also matches words that stem to it.
50
51use std::collections::{HashMap, HashSet};
52
53use parking_lot::RwLock;
54
55use super::{
56    Language, Purpose, Script, Token, Tokenizer, cjk_morph, language_code, light_stem,
57    parse_language_opt, split_whitespace_with_offsets, with_stemmers,
58};
59
60/// Default `max_token_length`: longer "words" are hashes, sequences and
61/// URLs, which no query types and which bloat the dictionary.
62pub const DEFAULT_MAX_TOKEN_LENGTH: usize = 64;
63
64/// Segmenters see the text in windows of at most this many characters.
65///
66/// ICU's dictionary segmentation of Han text is quadratic in the length of
67/// the run it is handed: one 2.4 MB Chinese book took 105 s as a single
68/// call and 0.2 s in windows. A window ends at the first whitespace or
69/// punctuation after `SEGMENT_WINDOW_SOFT` characters, or unconditionally at
70/// `SEGMENT_WINDOW_HARD` (text with neither in thousands of characters is not
71/// prose; a split there costs at most one word boundary).
72const SEGMENT_WINDOW_SOFT: usize = 1024;
73const SEGMENT_WINDOW_HARD: usize = 4096;
74
75/// Windows of `text` as (byte offset, slice), contiguous and covering.
76fn segment_windows(text: &str) -> Vec<(usize, &str)> {
77    let mut windows = Vec::new();
78    let mut start = 0usize;
79    let mut chars_in_window = 0usize;
80    for (offset, c) in text.char_indices() {
81        chars_in_window += 1;
82        let cut = chars_in_window >= SEGMENT_WINDOW_HARD
83            || (chars_in_window >= SEGMENT_WINDOW_SOFT && (c.is_whitespace() || is_break_punct(c)));
84        if cut {
85            let end = offset + c.len_utf8();
86            windows.push((start, &text[start..end]));
87            start = end;
88            chars_in_window = 0;
89        }
90    }
91    if start < text.len() || windows.is_empty() {
92        windows.push((start, &text[start..]));
93    }
94    windows
95}
96
97/// Split one segment into lowercase alphanumeric words at the punctuation
98/// the segmenter left inside it, the way a standard analyzer with the
99/// possessive and elision filters does:
100///
101/// - any character that is not a letter, digit or mark is a boundary
102///   (`state-of-the-art` → `state of the art`, `HbA1c/HDL-c` → `hba1c hdl c`,
103///   `end.of.sentence.Next` → `end of sentence next`);
104/// - an apostrophe between letters splits the word into parts: a trailing
105///   English contraction or possessive (`'s`, `'t`, `'re`, `'ve`, `'ll`,
106///   `'d`, `'m`) is dropped (`John's` → `john`, `don't` → `don`), leading
107///   elided articles and conjunctions (`l'`, `d'`, `qu'`, `dell'`, …) are
108///   dropped (`l'homme` → `homme`, `qu'il` → `il`, `O'Neil` → `neil`), and
109///   what remains are separate words (`aujourd'hui` → `aujourd hui`);
110/// - a dotted acronym is joined (`U.S.A.` → `usa`, `e.g.` → `eg`);
111/// - a dot between digits is kept (`3.14`, `0.05`, `1.2.3`) and a comma
112///   between digits is dropped (`1,000` → `1000`);
113/// - soft hyphens, zero-width joiners and other invisible joiners are
114///   dropped without splitting (`soft\u{ad}hyphen` → `softhyphen`).
115///
116/// Returns `(from, to, piece)` byte spans into `segment`.
117fn split_word(segment: &str) -> Vec<(usize, usize, String)> {
118    // Fast path: already a clean lowercase word.
119    if segment
120        .bytes()
121        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
122    {
123        return vec![(0, segment.len(), segment.to_string())];
124    }
125    let chars: Vec<(usize, char)> = segment.char_indices().collect();
126    let acronym = segment.contains('.') && is_dotted_acronym(segment);
127    let mut pieces: Vec<(usize, usize, String)> = Vec::new();
128    // Parts of the word joined by apostrophes so far (`rock'n'roll`).
129    let mut group: Vec<(usize, usize, String)> = Vec::new();
130    let mut piece = String::new();
131    let mut piece_from = 0usize;
132    let mut piece_to = 0usize;
133    for (i, &(offset, c)) in chars.iter().enumerate() {
134        let prev = (i > 0).then(|| chars[i - 1].1);
135        let next = chars.get(i + 1).map(|(_, n)| *n);
136        let between = |test: fn(char) -> bool| prev.is_some_and(test) && next.is_some_and(test);
137        let action = if c.is_alphanumeric() || is_mark(c) {
138            Piece::Keep
139        } else if is_joiner(c) {
140            Piece::Join
141        } else if is_apostrophe(c) && between(char::is_alphabetic) {
142            Piece::Apostrophe
143        } else if c == '.' && acronym {
144            Piece::Join
145        } else if c == '.' && between(|d| d.is_ascii_digit()) {
146            Piece::Keep
147        } else if c == ',' && between(|d| d.is_ascii_digit()) {
148            // Thousands separator: `1,000` → `1000`, the form people type.
149            Piece::Join
150        } else {
151            Piece::Boundary
152        };
153        match action {
154            Piece::Keep => {
155                if piece.is_empty() {
156                    piece_from = offset;
157                }
158                piece.extend(c.to_lowercase());
159                piece_to = offset + c.len_utf8();
160            }
161            Piece::Join => {}
162            Piece::Apostrophe => {
163                group.push((piece_from, piece_to, std::mem::take(&mut piece)));
164            }
165            Piece::Boundary => {
166                if !piece.is_empty() {
167                    group.push((piece_from, piece_to, std::mem::take(&mut piece)));
168                }
169                resolve_apostrophes(&mut group, &mut pieces);
170            }
171        }
172    }
173    if !piece.is_empty() {
174        group.push((piece_from, piece_to, piece));
175    }
176    resolve_apostrophes(&mut group, &mut pieces);
177    pieces
178}
179
180enum Piece {
181    Keep,
182    Join,
183    Apostrophe,
184    Boundary,
185}
186
187/// Emit the parts of an apostrophe-joined word: drop a trailing English
188/// contraction/possessive suffix and leading elided prefixes.
189fn resolve_apostrophes(
190    group: &mut Vec<(usize, usize, String)>,
191    pieces: &mut Vec<(usize, usize, String)>,
192) {
193    if group.len() > 1 {
194        if group
195            .last()
196            .is_some_and(|(_, _, part)| CONTRACTION_SUFFIXES.contains(&part.as_str()))
197        {
198            group.pop();
199        }
200        while group.len() > 1
201            && group.first().is_some_and(|(_, _, part)| {
202                part.chars().count() <= 2 || ELISION_PREFIXES.contains(&part.as_str())
203            })
204        {
205            group.remove(0);
206        }
207    }
208    pieces.append(group);
209}
210
211/// English contractions and the possessive: dropped after an apostrophe.
212const CONTRACTION_SUFFIXES: &[&str] = &["s", "t", "re", "ve", "ll", "d", "m"];
213
214/// Elided articles, prepositions and conjunctions of French, Italian and
215/// Catalan longer than two letters (shorter ones are dropped by length).
216const ELISION_PREFIXES: &[&str] = &[
217    "qu", "lorsqu", "jusqu", "puisqu", "quoiqu", "dell", "nell", "sull", "dall", "all", "coll",
218    "degl", "dagl", "negl", "sugl", "quest", "quell", "sant", "anch",
219];
220
221/// `U.S.A.`, `e.g.`: every run between dots is exactly one letter
222/// (`Ph.D.` splits into `ph d` like a standard analyzer).
223fn is_dotted_acronym(segment: &str) -> bool {
224    let mut runs = 0;
225    for run in segment.split('.') {
226        if run.is_empty() {
227            continue;
228        }
229        let mut letters = run.chars();
230        match (letters.next(), letters.next()) {
231            (Some(c), None) if c.is_alphabetic() => runs += 1,
232            _ => return false,
233        }
234    }
235    runs >= 2
236}
237
238fn is_mark(c: char) -> bool {
239    // Combining marks (Mn/Mc/Me) that survive NFKC, e.g. in Indic scripts.
240    matches!(c as u32, 0x0300..=0x036F | 0x0483..=0x0489 | 0x0591..=0x05BD | 0x0610..=0x061A
241        | 0x064B..=0x065F | 0x0900..=0x0903 | 0x093A..=0x094F | 0x0951..=0x0957 | 0x0962..=0x0963
242        | 0x0E31 | 0x0E34..=0x0E3A | 0x0E47..=0x0E4E | 0x1AB0..=0x1AFF | 0x1DC0..=0x1DFF
243        | 0x20D0..=0x20FF | 0xFE20..=0xFE2F)
244}
245
246/// Invisible characters that join the letters around them.
247fn is_joiner(c: char) -> bool {
248    matches!(
249        c,
250        '\u{00AD}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FEFF}' | '\u{034F}'
251    )
252}
253
254fn is_apostrophe(c: char) -> bool {
255    matches!(c, '\'' | '\u{2019}' | '\u{2018}' | '\u{02BC}' | '\u{FF07}')
256}
257
258/// Punctuation that ends a segmentation window: ASCII sentence and clause
259/// marks plus their CJK full-width forms.
260fn is_break_punct(c: char) -> bool {
261    matches!(
262        c,
263        '.' | ','
264            | ';'
265            | ':'
266            | '!'
267            | '?'
268            | ')'
269            | ']'
270            | '}'
271            | '。'
272            | ','
273            | '、'
274            | ';'
275            | ':'
276            | '!'
277            | '?'
278            | '」'
279            | '』'
280            | ')'
281            | '】'
282            | '〉'
283            | '》'
284    )
285}
286
287/// Word segmentation.
288#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
289pub enum Segmenter {
290    /// ICU4X word segmentation: dictionary words for Chinese and Japanese
291    /// (with their bigrams as variants), LSTM word breaks for Thai, Lao,
292    /// Khmer and Burmese, UAX #29 for everything else.
293    #[default]
294    Icu,
295    /// UAX #29 word boundaries (`float-zero` → `float`, `zero`; `p53`,
296    /// `co2` and `10.1007` stay whole) and character bigrams over runs of
297    /// Han, Hiragana and Katakana.
298    Unicode,
299    /// Split on whitespace, strip every non-alphanumeric character
300    /// (`float-zero` → `floatzero`, no CJK segmentation).
301    Simple,
302}
303
304/// Morphological normalisation.
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
306pub enum StemMode {
307    /// Inflection only (plural, case, gender), Lucene's light stemmers.
308    #[default]
309    Light,
310    /// Full Snowball stemming.
311    Snowball,
312    /// Keep every word as written.
313    None,
314}
315
316/// Treatment of Han characters.
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
318pub enum HanForm {
319    /// Index and query characters as written.
320    #[default]
321    AsWritten,
322    /// Fold traditional characters to simplified (OpenCC table), index and
323    /// query alike; Japanese dictionary tokens keep their surface and gain
324    /// the folded form as a variant.
325    Simplified,
326}
327
328/// Japanese and Korean analysis.
329#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
330pub enum CjkMode {
331    /// ICU dictionary words (Chinese and Japanese) plus bigrams; Korean by
332    /// UAX #29 (particles stay attached).
333    #[default]
334    Icu,
335    /// Dictionary morphology (`cjk-dict` feature): Korean always, Japanese
336    /// for kana runs and, when hinted `ja`, Han runs; particles and endings
337    /// dropped, base forms as variants.
338    Dictionary,
339}
340
341/// Options of a [`LexTokenizer`]; the parsed and rendered form of a
342/// `lex(...)` spec. Only values that differ from the defaults are rendered.
343#[derive(Debug, Clone, PartialEq, Eq)]
344pub struct LexOptions {
345    /// Field whose text values supply the language hint; `None` = no
346    /// per-document language, hints are ignored, `default` always applies.
347    pub by: Option<String>,
348    /// Language applied when no hint is present; `None` = no stemming or
349    /// stop list.
350    pub default: Option<Language>,
351    /// Drop the routed language's stop words (positions keep their gaps).
352    pub stop_words: bool,
353    pub segmenter: Segmenter,
354    pub stem: StemMode,
355    /// Keep the written word as the token and index stems and folded forms
356    /// as same-position variants.
357    pub variants: bool,
358    /// Fold diacritics of Latin, Cyrillic and Greek tokens.
359    pub fold: bool,
360    /// Drop tokens longer than this many characters (0 = unlimited).
361    pub max_token_length: usize,
362    pub han: HanForm,
363    pub cjk: CjkMode,
364}
365
366impl Default for LexOptions {
367    fn default() -> Self {
368        Self {
369            by: None,
370            default: None,
371            stop_words: false,
372            segmenter: Segmenter::Icu,
373            stem: StemMode::Light,
374            variants: true,
375            fold: true,
376            max_token_length: DEFAULT_MAX_TOKEN_LENGTH,
377            han: HanForm::AsWritten,
378            cjk: CjkMode::Icu,
379        }
380    }
381}
382
383impl LexOptions {
384    /// Parse the `key: value, ...` body of a `lex(...)` spec.
385    pub fn parse(params: &str) -> Result<Self, String> {
386        let mut options = Self::default();
387        let spec = format!("lex({params})");
388        let parse_bool = |key: &str, value: &str| -> Result<bool, String> {
389            match value {
390                "true" => Ok(true),
391                "false" => Ok(false),
392                other => Err(format!(
393                    "tokenizer spec '{spec}': '{key}' must be true or false, got '{other}'"
394                )),
395            }
396        };
397        let choice = |key: &str, value: &str, allowed: &[&str]| -> Result<(), String> {
398            if allowed.contains(&value) {
399                Ok(())
400            } else {
401                Err(format!(
402                    "tokenizer spec '{spec}': '{key}' must be one of {}, got '{value}'",
403                    allowed.join(", ")
404                ))
405            }
406        };
407        for param in params.split(',') {
408            let param = param.trim();
409            if param.is_empty() {
410                continue;
411            }
412            let Some((key, value)) = param.split_once(':') else {
413                return Err(format!(
414                    "tokenizer spec '{spec}': parameter '{param}' must be 'key: value'"
415                ));
416            };
417            let (key, value) = (key.trim(), value.trim());
418            match key {
419                "by" if !value.is_empty() => options.by = Some(value.to_string()),
420                "by" => return Err(format!("tokenizer spec '{spec}': 'by' needs a field name")),
421                "default" => {
422                    options.default = match value {
423                        "none" => None,
424                        other => Some(parse_language_opt(other).ok_or_else(|| {
425                            format!("tokenizer spec '{spec}': unknown default language '{other}'")
426                        })?),
427                    };
428                }
429                "stop_words" => options.stop_words = parse_bool(key, value)?,
430                "variants" => options.variants = parse_bool(key, value)?,
431                "fold" => options.fold = parse_bool(key, value)?,
432                "segmenter" => {
433                    choice(key, value, &["icu", "unicode", "simple"])?;
434                    options.segmenter = match value {
435                        "icu" => Segmenter::Icu,
436                        "unicode" => Segmenter::Unicode,
437                        _ => Segmenter::Simple,
438                    };
439                }
440                "stem" => {
441                    choice(key, value, &["light", "snowball", "none"])?;
442                    options.stem = match value {
443                        "light" => StemMode::Light,
444                        "snowball" => StemMode::Snowball,
445                        _ => StemMode::None,
446                    };
447                }
448                "han" => {
449                    choice(key, value, &["as_written", "simplified"])?;
450                    options.han = if value == "simplified" {
451                        HanForm::Simplified
452                    } else {
453                        HanForm::AsWritten
454                    };
455                }
456                "cjk" => {
457                    choice(key, value, &["icu", "dictionary"])?;
458                    options.cjk = if value == "dictionary" {
459                        if !cjk_morph::available() {
460                            return Err(format!(
461                                "tokenizer spec '{spec}': 'cjk: dictionary' needs a build with the cjk-dict feature (Japanese and Korean dictionaries)"
462                            ));
463                        }
464                        CjkMode::Dictionary
465                    } else {
466                        CjkMode::Icu
467                    };
468                }
469                "max_token_length" => {
470                    options.max_token_length = value.parse::<usize>().map_err(|_| {
471                        format!(
472                            "tokenizer spec '{spec}': 'max_token_length' must be a number, got '{value}'"
473                        )
474                    })?;
475                }
476                other => {
477                    return Err(format!(
478                        "tokenizer spec '{spec}': unknown parameter '{other}'"
479                    ));
480                }
481            }
482        }
483        Ok(options)
484    }
485
486    /// Render the `key: value, ...` body: non-default options in canonical
487    /// order.
488    fn render(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
489        let defaults = Self::default();
490        let mut parts: Vec<String> = Vec::new();
491        if let Some(by) = &self.by {
492            parts.push(format!("by: {by}"));
493        }
494        if let Some(language) = self.default {
495            parts.push(format!("default: {}", language_code(language)));
496        }
497        if self.stop_words != defaults.stop_words {
498            parts.push(format!("stop_words: {}", self.stop_words));
499        }
500        if self.segmenter != defaults.segmenter {
501            parts.push(format!(
502                "segmenter: {}",
503                match self.segmenter {
504                    Segmenter::Icu => "icu",
505                    Segmenter::Unicode => "unicode",
506                    Segmenter::Simple => "simple",
507                }
508            ));
509        }
510        if self.stem != defaults.stem {
511            parts.push(format!(
512                "stem: {}",
513                match self.stem {
514                    StemMode::Light => "light",
515                    StemMode::Snowball => "snowball",
516                    StemMode::None => "none",
517                }
518            ));
519        }
520        if self.variants != defaults.variants {
521            parts.push(format!("variants: {}", self.variants));
522        }
523        if self.fold != defaults.fold {
524            parts.push(format!("fold: {}", self.fold));
525        }
526        if self.max_token_length != defaults.max_token_length {
527            parts.push(format!("max_token_length: {}", self.max_token_length));
528        }
529        if self.han != defaults.han {
530            parts.push("han: simplified".to_string());
531        }
532        if self.cjk != defaults.cjk {
533            parts.push("cjk: dictionary".to_string());
534        }
535        write!(f, "lex({})", parts.join(", "))
536    }
537}
538
539impl std::fmt::Display for LexOptions {
540    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
541        self.render(f)
542    }
543}
544
545/// Parsed form of a tokenizer name in the schema: a registered name
546/// (`simple`, `en_stem`, ...) or a `lex(...)` spec. The canonical string
547/// form is stored in `FieldEntry::tokenizer`.
548#[derive(Debug, Clone, PartialEq, Eq)]
549pub enum TokenizerSpec {
550    Named(String),
551    Lex(LexOptions),
552}
553
554impl TokenizerSpec {
555    /// Parse a tokenizer name or `lex(...)` spec.
556    pub fn parse(spec: &str) -> Result<TokenizerSpec, String> {
557        let spec = spec.trim();
558        let Some(rest) = spec.strip_prefix("lex(") else {
559            if spec.is_empty() || spec.contains(['(', ')', ':', ',']) {
560                return Err(format!("invalid tokenizer spec '{spec}'"));
561            }
562            return Ok(TokenizerSpec::Named(spec.to_string()));
563        };
564        let Some(params) = rest.strip_suffix(')') else {
565            return Err(format!("tokenizer spec '{spec}' is missing ')'"));
566        };
567        LexOptions::parse(params).map(TokenizerSpec::Lex)
568    }
569
570    /// The options of a `lex(...)` spec.
571    pub fn lex(&self) -> Option<&LexOptions> {
572        match self {
573            TokenizerSpec::Named(_) => None,
574            TokenizerSpec::Lex(options) => Some(options),
575        }
576    }
577
578    /// Field whose values hint the tokenizer, for `lex` specs with `by`.
579    pub fn hint_field(&self) -> Option<&str> {
580        self.lex().and_then(|options| options.by.as_deref())
581    }
582
583    /// Whether the spec indexes originals next to their variants, so exact
584    /// (phrase, term) queries match the written form and match queries the
585    /// stem.
586    pub fn keeps_original(&self) -> bool {
587        self.lex().is_some_and(|options| options.variants)
588    }
589
590    /// Build the tokenizer described by a `lex` spec.
591    pub fn dynamic_tokenizer(&self) -> Option<super::BoxedTokenizer> {
592        self.lex()
593            .map(|options| Box::new(LexTokenizer::new(options.clone())) as super::BoxedTokenizer)
594    }
595}
596
597impl std::fmt::Display for TokenizerSpec {
598    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
599        match self {
600            TokenizerSpec::Named(name) => f.write_str(name),
601            TokenizerSpec::Lex(options) => options.render(f),
602        }
603    }
604}
605
606/// The tokenizer of a `lex(...)` field (module docs).
607#[derive(Debug, Clone, Default)]
608pub struct LexTokenizer {
609    options: LexOptions,
610}
611
612impl LexTokenizer {
613    pub fn new(options: LexOptions) -> Self {
614        Self { options }
615    }
616
617    pub fn options(&self) -> &LexOptions {
618        &self.options
619    }
620
621    /// Languages of a hint (`"ru,en"`), in order, plus the Japanese and
622    /// Korean flags; the default language when the hint names none. Specs
623    /// without `by` ignore hints.
624    fn hints(&self, hint: Option<&str>) -> Hints {
625        let mut hints = Hints::default();
626        if self.options.by.is_some()
627            && let Some(hint) = hint.map(str::trim).filter(|hint| !hint.is_empty())
628        {
629            for part in hint.split(',') {
630                let part = part.trim();
631                match part.to_ascii_lowercase().as_str() {
632                    "ja" | "jpn" | "japanese" => hints.japanese = true,
633                    "ko" | "kor" | "korean" => hints.korean = true,
634                    _ => {
635                        if let Some(language) = parse_language_opt(part)
636                            && !hints.languages.contains(&language)
637                        {
638                            hints.languages.push(language);
639                        }
640                    }
641                }
642            }
643        }
644        if hints.languages.is_empty() {
645            hints.languages.extend(self.options.default);
646        }
647        hints
648    }
649
650    fn run(&self, text: &str, hints: &Hints, purpose: Purpose) -> Vec<Token> {
651        let stops: Vec<Option<&'static HashSet<String>>> = hints
652            .languages
653            .iter()
654            .map(|language| {
655                self.options
656                    .stop_words
657                    .then(|| stop_word_set(*language))
658                    .flatten()
659            })
660            .collect();
661        if hints.languages.is_empty() || self.options.stem != StemMode::Snowball {
662            self.walk(text, &Ctx::new(hints, &stops, &[]), purpose)
663        } else {
664            with_stemmers(&hints.languages, |stemmers| {
665                self.walk(text, &Ctx::new(hints, &stops, stemmers), purpose)
666            })
667        }
668    }
669
670    fn walk(&self, text: &str, ctx: &Ctx<'_>, purpose: Purpose) -> Vec<Token> {
671        let mut emitter = Emitter {
672            options: &self.options,
673            ctx,
674            purpose,
675            tokens: Vec::with_capacity(text.len() / 5),
676            position: 0,
677            run: Vec::new(),
678            run_end: 0,
679        };
680        match self.options.segmenter {
681            Segmenter::Simple => {
682                for (offset, word) in split_whitespace_with_offsets(text) {
683                    emitter.word(offset, word);
684                }
685            }
686            Segmenter::Unicode => {
687                use unicode_segmentation::UnicodeSegmentation;
688                for (offset, word) in text.unicode_word_indices() {
689                    if word.chars().all(|c| CjkScript::of(c).is_cjk()) {
690                        emitter.cjk_chars(offset, word);
691                    } else {
692                        emitter.word(offset, word);
693                    }
694                }
695            }
696            Segmenter::Icu if self.options.cjk == CjkMode::Dictionary => {
697                for (start, end, kind) in morph_spans(text, ctx.hints) {
698                    for (offset, window) in segment_windows(&text[start..end]) {
699                        let base = start + offset;
700                        match kind {
701                            SpanKind::Japanese => {
702                                emitter.morph_run(base, window, cjk_morph::japanese)
703                            }
704                            SpanKind::Korean => emitter.morph_run(base, window, cjk_morph::korean),
705                            SpanKind::Icu => emitter.icu_span(base, window),
706                        }
707                    }
708                }
709            }
710            Segmenter::Icu => {
711                for (offset, window) in segment_windows(text) {
712                    emitter.icu_span(offset, window);
713                }
714            }
715        }
716        emitter.flush_run();
717        emitter.tokens
718    }
719}
720
721impl Tokenizer for LexTokenizer {
722    fn tokenize(&self, text: &str) -> Vec<Token> {
723        self.run(text, &self.hints(None), Purpose::Index)
724    }
725
726    fn tokenize_with(&self, text: &str, hint: Option<&str>, purpose: Purpose) -> Vec<Token> {
727        self.run(text, &self.hints(hint), purpose)
728    }
729}
730
731/// Languages of one tokenization.
732#[derive(Debug, Clone, Default, PartialEq, Eq)]
733struct Hints {
734    languages: Vec<Language>,
735    japanese: bool,
736    korean: bool,
737}
738
739/// Per-call context.
740struct Ctx<'a> {
741    hints: &'a Hints,
742    stops: &'a [Option<&'static HashSet<String>>],
743    /// Snowball stemmers aligned with `hints.languages` (empty unless the
744    /// mode is Snowball).
745    stemmers: &'a [&'a rust_stemmers::Stemmer],
746}
747
748impl<'a> Ctx<'a> {
749    fn new(
750        hints: &'a Hints,
751        stops: &'a [Option<&'static HashSet<String>>],
752        stemmers: &'a [&'a rust_stemmers::Stemmer],
753    ) -> Self {
754        Self {
755            hints,
756            stops,
757            stemmers,
758        }
759    }
760}
761
762/// Script class of a character for CJK handling.
763#[derive(Debug, Clone, Copy, PartialEq, Eq)]
764enum CjkScript {
765    Han,
766    Kana,
767    Hangul,
768    Other,
769}
770
771impl CjkScript {
772    #[inline]
773    fn of(c: char) -> Self {
774        match c as u32 {
775            0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x20000..=0x2FA1F => Self::Han,
776            0x3040..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9F => Self::Kana,
777            0xAC00..=0xD7AF
778            | 0x1100..=0x11FF
779            | 0x3130..=0x318F
780            | 0xA960..=0xA97F
781            | 0xD7B0..=0xD7FF => Self::Hangul,
782            _ => Self::Other,
783        }
784    }
785
786    /// Han or kana: the scripts that are bigrammed instead of stemmed.
787    #[inline]
788    fn is_cjk(self) -> bool {
789        matches!(self, Self::Han | Self::Kana)
790    }
791}
792
793/// How a span of text is analysed under `cjk: dictionary`.
794#[derive(Debug, Clone, Copy, PartialEq, Eq)]
795enum SpanKind {
796    Japanese,
797    Korean,
798    Icu,
799}
800
801/// Split `text` into maximal spans: Hangul runs go to the Korean
802/// dictionary, kana runs (plus Han when hinted Japanese) to the Japanese
803/// one, everything else to ICU. Whitespace ends a run, so each Japanese
804/// sentence or Korean word group is analysed whole.
805fn morph_spans(text: &str, hints: &Hints) -> Vec<(usize, usize, SpanKind)> {
806    let classify = |c: char| match CjkScript::of(c) {
807        CjkScript::Hangul => SpanKind::Korean,
808        CjkScript::Kana => SpanKind::Japanese,
809        CjkScript::Han if hints.japanese => SpanKind::Japanese,
810        _ => SpanKind::Icu,
811    };
812    let mut spans: Vec<(usize, usize, SpanKind)> = Vec::new();
813    for (offset, c) in text.char_indices() {
814        let kind = classify(c);
815        let end = offset + c.len_utf8();
816        match spans.last_mut() {
817            Some((_, last_end, last_kind)) if *last_kind == kind && *last_end == offset => {
818                *last_end = end;
819            }
820            _ => spans.push((offset, end, kind)),
821        }
822    }
823    spans
824}
825
826struct Emitter<'a> {
827    options: &'a LexOptions,
828    ctx: &'a Ctx<'a>,
829    purpose: Purpose,
830    tokens: Vec<Token>,
831    position: u32,
832    /// Contiguous run of single CJK characters: (byte offset, char).
833    run: Vec<(usize, char)>,
834    run_end: usize,
835}
836
837impl Emitter<'_> {
838    /// Segment `text` (at byte `base` of the document) with ICU and emit
839    /// its words.
840    fn icu_span(&mut self, base: usize, text: &str) {
841        let segmenter = icu_word_segmenter();
842        let mut start = 0usize;
843        for (end, kind) in segmenter.segment_str(text).iter_with_word_type() {
844            let segment = &text[start..end];
845            let offset = base + start;
846            start = end;
847            if !kind.is_word_like() {
848                continue;
849            }
850            if segment.chars().all(|c| CjkScript::of(c).is_cjk()) {
851                self.cjk_word(offset, segment);
852            } else {
853                self.word(offset, segment);
854            }
855        }
856    }
857
858    /// A word from the segmenter (non-CJK): NFKC, split at punctuation the
859    /// segmenter kept inside it, lowercase, then routed to its language.
860    fn word(&mut self, offset: usize, raw: &str) {
861        self.flush_run();
862        if raw.is_empty() {
863            return;
864        }
865        if raw.is_ascii() {
866            for (from, to, piece) in split_word(raw) {
867                self.emit_word(piece, offset + from, offset + to);
868            }
869        } else {
870            use unicode_normalization::UnicodeNormalization;
871            let normalized: String = raw.nfkc().collect();
872            // Offsets of pieces point into the normalized form; report the
873            // whole raw segment as the source span when NFKC changed it.
874            let same_length = normalized.len() == raw.len();
875            for (from, to, piece) in split_word(&normalized) {
876                if same_length {
877                    self.emit_word(piece, offset + from, offset + to);
878                } else {
879                    self.emit_word(piece, offset, offset + raw.len());
880                }
881            }
882        }
883    }
884
885    /// Characters of a CJK segment without dictionary word boundaries: they
886    /// join the current run and are bigrammed.
887    fn cjk_chars(&mut self, offset: usize, word: &str) {
888        if !self.run.is_empty() && offset != self.run_end {
889            self.flush_run();
890        }
891        let mut at = offset;
892        for c in word.chars() {
893            self.run.push((at, c));
894            at += c.len_utf8();
895        }
896        self.run_end = at;
897    }
898
899    /// A CJK segment from the ICU dictionary: a single character joins the
900    /// bigram run; a word is one token with its bigrams as variants.
901    fn cjk_word(&mut self, offset: usize, word: &str) {
902        if word.chars().nth(1).is_none() {
903            self.cjk_chars(offset, word);
904            return;
905        }
906        self.flush_run();
907        use unicode_normalization::UnicodeNormalization;
908        let text = self.simplify(word.nfkc().collect());
909        let end = offset + word.len();
910        let position = self.position;
911        self.tokens
912            .push(Token::new(text.clone(), position, offset, end));
913        if self.purpose == Purpose::Index {
914            self.push_bigram_variants(&text, position, offset, end);
915        }
916        self.position += 1;
917    }
918
919    /// Bigrams of a CJK word of three or more characters, as variants.
920    fn push_bigram_variants(&mut self, text: &str, position: u32, from: usize, to: usize) {
921        let chars: Vec<char> = text.chars().collect();
922        if chars.len() < 3 || !chars.iter().all(|c| CjkScript::of(*c).is_cjk()) {
923            return;
924        }
925        for pair in chars.windows(2) {
926            let mut bigram = String::with_capacity(8);
927            bigram.push(pair[0]);
928            bigram.push(pair[1]);
929            self.tokens
930                .push(Token::variant_of(bigram, position, from, to));
931        }
932    }
933
934    fn flush_run(&mut self) {
935        match self.run.len() {
936            0 => {}
937            1 => {
938                let (offset, c) = self.run[0];
939                let text = self.simplify(c.to_string());
940                self.tokens.push(Token::new(
941                    text,
942                    self.position,
943                    offset,
944                    offset + c.len_utf8(),
945                ));
946                self.position += 1;
947            }
948            _ => {
949                for pair in self.run.windows(2) {
950                    let (start, a) = pair[0];
951                    let (next, b) = pair[1];
952                    let mut text = String::with_capacity(8);
953                    text.push(a);
954                    text.push(b);
955                    let text = self.simplify(text);
956                    self.tokens
957                        .push(Token::new(text, self.position, start, next + b.len_utf8()));
958                    self.position += 1;
959                }
960            }
961        }
962        self.run.clear();
963    }
964
965    /// Traditional-to-simplified folding of a Han token when enabled.
966    fn simplify(&self, text: String) -> String {
967        if self.options.han == HanForm::Simplified
968            && text.chars().any(|c| CjkScript::of(c) == CjkScript::Han)
969        {
970            han_to_simplified(&text)
971        } else {
972            text
973        }
974    }
975
976    /// Whether a token exceeds `max_token_length` (a byte-length check first,
977    /// since a token cannot have more characters than bytes).
978    fn too_long(&self, text: &str) -> bool {
979        let max = self.options.max_token_length;
980        max > 0 && text.len() > max && text.chars().count() > max
981    }
982
983    /// Morphemes of a Japanese or Korean run: function morphemes keep
984    /// their position and are dropped; content morphemes are emitted with
985    /// their base form, their simplified Han form and, when indexing, their
986    /// bigrams as variants.
987    fn morph_run(&mut self, base: usize, text: &str, analyse: fn(&str) -> Vec<cjk_morph::Morph>) {
988        self.flush_run();
989        for morph in analyse(text) {
990            if !morph.content {
991                self.position += 1;
992                continue;
993            }
994            use unicode_normalization::UnicodeNormalization;
995            let surface: String = morph.surface.nfkc().collect();
996            if self.too_long(&surface) {
997                self.position += 1;
998                continue;
999            }
1000            let (from, to) = (base + morph.start, base + morph.end);
1001            let position = self.position;
1002            match self.purpose {
1003                Purpose::Index => {
1004                    let start = self.tokens.len();
1005                    self.tokens
1006                        .push(Token::new(surface.clone(), position, from, to));
1007                    if let Some(lemma) = morph.lemma {
1008                        self.push_variant(start, lemma, position, from, to);
1009                    }
1010                    let simplified = self.simplify(surface.clone());
1011                    self.push_variant(start, simplified, position, from, to);
1012                    self.push_bigram_variants(&surface, position, from, to);
1013                }
1014                Purpose::Match => {
1015                    let form = morph.lemma.unwrap_or(surface);
1016                    self.tokens.push(Token::new(form, position, from, to));
1017                }
1018                Purpose::Exact => {
1019                    self.tokens.push(Token::new(surface, position, from, to));
1020                }
1021            }
1022            self.position += 1;
1023        }
1024    }
1025
1026    /// A variant of the token at `self.tokens[start]`, unless the same text
1027    /// is already emitted at this position.
1028    fn push_variant(&mut self, start: usize, text: String, position: u32, from: usize, to: usize) {
1029        if self.tokens[start..].iter().any(|t| t.text == text) {
1030            return;
1031        }
1032        self.tokens
1033            .push(Token::variant_of(text, position, from, to));
1034    }
1035
1036    /// Route a cleaned word to its language and emit the forms the purpose
1037    /// asks for. Every path consumes one position.
1038    fn emit_word(&mut self, word: String, from: usize, to: usize) {
1039        let options = self.options;
1040        let script = Script::of_token(&word);
1041        let route = self
1042            .ctx
1043            .hints
1044            .languages
1045            .iter()
1046            .position(|language| language.script() == script);
1047
1048        // Orthographic normalisation of the written form itself.
1049        let word = match script {
1050            Script::Arabic => light_stem::arabic_normalize(&word).unwrap_or(word),
1051            Script::Cyrillic if word.contains('ё') => word.replace('ё', "е"),
1052            _ => word,
1053        };
1054
1055        if let Some(index) = route
1056            && self.ctx.stops[index].is_some_and(|set| set.contains(word.as_str()))
1057        {
1058            self.position += 1;
1059            return;
1060        }
1061        if self.too_long(&word) {
1062            self.position += 1;
1063            return;
1064        }
1065
1066        let stem: Option<String> = route.and_then(|index| match options.stem {
1067            StemMode::None => None,
1068            StemMode::Light => light_stem::light_stem(self.ctx.hints.languages[index], &word),
1069            StemMode::Snowball => {
1070                let stemmer = self.ctx.stemmers.get(index)?;
1071                match stemmer.stem(&word) {
1072                    std::borrow::Cow::Borrowed(_) => None,
1073                    std::borrow::Cow::Owned(stemmed) => (stemmed != word).then_some(stemmed),
1074                }
1075            }
1076        });
1077
1078        let position = self.position;
1079        match self.purpose {
1080            Purpose::Index if options.variants => {
1081                let start = self.tokens.len();
1082                let folded = options.fold.then(|| fold_diacritics(&word)).flatten();
1083                let folded_stem = options
1084                    .fold
1085                    .then(|| stem.as_deref().and_then(fold_diacritics))
1086                    .flatten();
1087                self.tokens.push(Token::new(word, position, from, to));
1088                for variant in [stem, folded, folded_stem].into_iter().flatten() {
1089                    self.push_variant(start, variant, position, from, to);
1090                }
1091            }
1092            Purpose::Exact if options.variants => {
1093                // The written form is indexed as the token; its stem and
1094                // folded form are variants at the same position.
1095                self.tokens.push(Token::new(word, position, from, to));
1096            }
1097            Purpose::Index | Purpose::Match | Purpose::Exact => {
1098                // Without variants the index holds one form per word, the
1099                // (folded) stem, so every query form is that.
1100                let base = stem.unwrap_or(word);
1101                let out = if options.fold && !options.variants {
1102                    fold_diacritics(&base).unwrap_or(base)
1103                } else {
1104                    base
1105                };
1106                self.tokens.push(Token::new(out, position, from, to));
1107            }
1108        }
1109        self.position += 1;
1110    }
1111}
1112
1113/// Character-level traditional-to-simplified conversion (OpenCC
1114/// `TSCharacters` table, see `han_t2s`).
1115fn han_to_simplified(text: &str) -> String {
1116    text.chars()
1117        .map(|c| super::han_t2s::to_simplified(c).unwrap_or(c))
1118        .collect()
1119}
1120
1121/// Diacritic-free form of a Latin, Cyrillic or Greek word (compatibility
1122/// decomposition, combining marks dropped, lowercased), or `None` when the
1123/// word has no diacritics or belongs to another script (whose combining
1124/// marks are letters in their own right).
1125fn fold_diacritics(word: &str) -> Option<String> {
1126    if word.is_ascii() {
1127        return None;
1128    }
1129    if !matches!(
1130        Script::of_token(word),
1131        Script::Latin | Script::Cyrillic | Script::Greek
1132    ) {
1133        return None;
1134    }
1135    use unicode_normalization::UnicodeNormalization;
1136    use unicode_normalization::char::is_combining_mark;
1137    let folded: String = word
1138        .nfkd()
1139        .filter(|c| !is_combining_mark(*c))
1140        .flat_map(|c| c.to_lowercase())
1141        .collect();
1142    (folded != word).then_some(folded)
1143}
1144
1145/// The process-wide ICU4X word segmenter (compiled data).
1146fn icu_word_segmenter() -> &'static icu_segmenter::WordSegmenterBorrowed<'static> {
1147    static SEGMENTER: std::sync::OnceLock<icu_segmenter::WordSegmenterBorrowed<'static>> =
1148        std::sync::OnceLock::new();
1149    SEGMENTER.get_or_init(|| {
1150        icu_segmenter::WordSegmenter::new_auto(
1151            icu_segmenter::options::WordBreakInvariantOptions::default(),
1152        )
1153    })
1154}
1155
1156/// Stop words of a language (NLTK lists), shared for the process lifetime.
1157/// Entries of the NLTK lists that are pieces of contractions and elisions
1158/// (`don't` → `don`, `t`; `l'homme` → `l`, `homme`) rather than words. The
1159/// tokenizer never produces those pieces, and as stop words they would
1160/// delete real single-letter tokens: `vitamin D`, `T cell`, `Y chromosome`.
1161fn is_stop_list_fragment(language: Language, word: &str) -> bool {
1162    if word.contains('\'') || word.contains('\u{2019}') {
1163        return true;
1164    }
1165    match language {
1166        Language::English => matches!(
1167            word,
1168            "s" | "t" | "d" | "ll" | "m" | "o" | "re" | "ve" | "y" | "ain" | "ma"
1169        ),
1170        Language::French => matches!(word, "c" | "d" | "j" | "l" | "m" | "n" | "s" | "t" | "qu"),
1171        Language::Italian => matches!(word, "l" | "c" | "d" | "m" | "n" | "s" | "t" | "v"),
1172        _ => false,
1173    }
1174}
1175
1176fn stop_word_set(language: Language) -> Option<&'static HashSet<String>> {
1177    static SETS: std::sync::OnceLock<RwLock<HashMap<Language, &'static HashSet<String>>>> =
1178        std::sync::OnceLock::new();
1179    let sets = SETS.get_or_init(|| RwLock::new(HashMap::new()));
1180    if let Some(set) = sets.read().get(&language) {
1181        return Some(set);
1182    }
1183    let set: &'static HashSet<String> = Box::leak(Box::new(
1184        stop_words::get(language.to_stop_words_language())
1185            .iter()
1186            .filter(|word| !is_stop_list_fragment(language, word))
1187            .map(|word| word.to_string())
1188            .collect(),
1189    ));
1190    Some(*sets.write().entry(language).or_insert(set))
1191}
1192
1193#[cfg(test)]
1194mod tests {
1195    use super::*;
1196
1197    fn texts(tokens: &[Token]) -> Vec<(u32, String, bool)> {
1198        tokens
1199            .iter()
1200            .map(|t| (t.position, t.text.clone(), t.variant))
1201            .collect()
1202    }
1203
1204    fn lex(spec: &str) -> LexTokenizer {
1205        LexTokenizer::new(LexOptions::parse(spec).unwrap())
1206    }
1207
1208    #[test]
1209    fn split_word_cleans_punctuation_like_a_standard_analyzer() {
1210        let words = |s: &str| {
1211            split_word(s)
1212                .into_iter()
1213                .map(|(_, _, w)| w)
1214                .collect::<Vec<_>>()
1215        };
1216        assert_eq!(words("state-of-the-art"), ["state", "of", "the", "art"]);
1217        assert_eq!(words("HbA1c/HDL-c"), ["hba1c", "hdl", "c"]);
1218        assert_eq!(
1219            words("end.of.sentence.Next"),
1220            ["end", "of", "sentence", "next"]
1221        );
1222        assert_eq!(words("don't"), ["don"]);
1223        assert_eq!(words("it's"), ["it"]);
1224        assert_eq!(words("we're"), ["we"]);
1225        assert_eq!(words("O'Neil"), ["neil"]);
1226        assert_eq!(words("rock'n'roll"), ["rock", "n", "roll"]);
1227        assert_eq!(words("John\u{2019}s"), ["john"]);
1228        assert_eq!(words("cats'"), ["cats"]);
1229        assert_eq!(words("l'homme"), ["homme"]);
1230        assert_eq!(words("qu'il"), ["il"]);
1231        assert_eq!(words("dell'acqua"), ["acqua"]);
1232        assert_eq!(words("aujourd'hui"), ["aujourd", "hui"]);
1233        assert_eq!(words("'quoted'"), ["quoted"]);
1234        assert_eq!(words("U.S.A."), ["usa"]);
1235        assert_eq!(words("e.g."), ["eg"]);
1236        assert_eq!(words("Ph.D."), ["ph", "d"]);
1237        assert_eq!(words("3.14"), ["3.14"]);
1238        assert_eq!(words("p<0.05"), ["p", "0.05"]);
1239        assert_eq!(words("1.2.3"), ["1.2.3"]);
1240        assert_eq!(words("1,000,000"), ["1000000"]);
1241        assert_eq!(words("word..."), ["word"]);
1242        assert_eq!(words("soft\u{ad}hyphen"), ["softhyphen"]);
1243        assert_eq!(words("zero\u{200d}width"), ["zerowidth"]);
1244        assert_eq!(words("(test)"), ["test"]);
1245        assert_eq!(words("C++"), ["c"]);
1246        assert_eq!(words("#tag"), ["tag"]);
1247        assert_eq!(words("α-synuclein"), ["α", "synuclein"]);
1248        assert_eq!(words("---"), Vec::<String>::new());
1249        // Byte spans point at the pieces in the segment.
1250        assert_eq!(
1251            split_word("Foo-Bar"),
1252            vec![(0, 3, "foo".to_string()), (4, 7, "bar".to_string())]
1253        );
1254    }
1255
1256    #[test]
1257    fn segment_windows_cut_at_punctuation_after_the_soft_size_and_cover_the_text() {
1258        let sentence = "量子计算机的研究进展,";
1259        let text: String = sentence.repeat(2000);
1260        let windows = segment_windows(&text);
1261        assert!(windows.len() > 1);
1262        let mut expected_start = 0;
1263        for (offset, window) in &windows {
1264            assert_eq!(*offset, expected_start);
1265            expected_start += window.len();
1266            let chars = window.chars().count();
1267            assert!(chars <= SEGMENT_WINDOW_SOFT + sentence.chars().count());
1268            assert!(window.ends_with(',') || expected_start == text.len());
1269        }
1270        assert_eq!(expected_start, text.len());
1271
1272        // No break characters at all: hard cuts, still covering.
1273        let solid: String = "的".repeat(10_000);
1274        let windows = segment_windows(&solid);
1275        assert_eq!(windows.len(), 10_000 / SEGMENT_WINDOW_HARD + 1);
1276        assert_eq!(
1277            windows.iter().map(|(_, w)| w.len()).sum::<usize>(),
1278            solid.len()
1279        );
1280        assert_eq!(segment_windows(""), vec![(0, "")]);
1281    }
1282
1283    #[test]
1284    fn long_han_run_tokenizes_in_bounded_time_with_continuous_positions() {
1285        let tokenizer = lex("by: languages, default: en, han: simplified");
1286        let text: String = "量子计算机的研究进展".repeat(20_000);
1287        let started = std::time::Instant::now();
1288        let tokens = tokenizer.tokenize_with(&text, Some("zh"), Purpose::Index);
1289        assert!(
1290            started.elapsed() < std::time::Duration::from_secs(20),
1291            "200k Han characters took {:?}",
1292            started.elapsed()
1293        );
1294        assert!(tokens.len() > 20_000);
1295        let mut last_position = 0;
1296        let mut last_end = 0;
1297        for token in tokens.iter().filter(|t| !t.variant) {
1298            assert!(token.position >= last_position);
1299            assert!(token.offset_from >= last_end || token.offset_from == last_end);
1300            last_position = token.position;
1301            last_end = token.offset_to;
1302        }
1303        assert_eq!(last_end, text.len());
1304    }
1305
1306    #[test]
1307    fn variants_index_stem_and_folded_forms_next_to_the_written_word() {
1308        let tokenizer = lex("by: languages, default: en, stop_words: true");
1309        let tokens = tokenizer.tokenize("The cell membranes of résumés");
1310        assert_eq!(
1311            texts(&tokens),
1312            vec![
1313                (1, "cell".to_string(), false),
1314                (2, "membranes".to_string(), false),
1315                (2, "membrane".to_string(), true),
1316                (4, "résumés".to_string(), false),
1317                (4, "résumé".to_string(), true),
1318                (4, "resumes".to_string(), true),
1319                (4, "resume".to_string(), true),
1320            ]
1321        );
1322        // A match query uses the stem, a phrase the written form; never variants.
1323        let matched = tokenizer.tokenize_with("cell membranes", Some("en"), Purpose::Match);
1324        assert_eq!(
1325            texts(&matched),
1326            vec![
1327                (0, "cell".to_string(), false),
1328                (1, "membrane".to_string(), false)
1329            ]
1330        );
1331        let exact = tokenizer.tokenize_with("cell membranes", Some("en"), Purpose::Exact);
1332        assert_eq!(
1333            texts(&exact),
1334            vec![
1335                (0, "cell".to_string(), false),
1336                (1, "membranes".to_string(), false)
1337            ]
1338        );
1339        // An unrecognised hint falls back to the default language.
1340        let fallback = tokenizer.tokenize_with("membranes", Some("xx"), Purpose::Match);
1341        assert_eq!(fallback[0].text, "membrane");
1342        // No language at all: the written form.
1343        let none = lex("").tokenize_with("membranes", None, Purpose::Match);
1344        assert_eq!(none[0].text, "membranes");
1345    }
1346
1347    #[test]
1348    fn without_variants_the_folded_stem_replaces_the_word_for_every_purpose() {
1349        let tokenizer = lex("default: en, stem: snowball, variants: false");
1350        for purpose in [Purpose::Index, Purpose::Match, Purpose::Exact] {
1351            let tokens = tokenizer.tokenize_with("Running cafés", None, purpose);
1352            assert_eq!(
1353                texts(&tokens),
1354                vec![
1355                    (0, "run".to_string(), false),
1356                    (1, "cafe".to_string(), false)
1357                ],
1358                "{purpose:?}"
1359            );
1360        }
1361    }
1362
1363    #[test]
1364    fn icu_segments_cjk_words_with_bigram_variants_and_thai() {
1365        let tokenizer = lex("");
1366        let tokens = tokenizer.tokenize("量子コンピュータの研究");
1367        let words: Vec<(u32, &str, bool)> = tokens
1368            .iter()
1369            .map(|t| (t.position, t.text.as_str(), t.variant))
1370            .collect();
1371        assert_eq!(words[0], (0, "量子", false));
1372        // A dictionary word of three or more characters carries its bigrams.
1373        let computer: Vec<&(u32, &str, bool)> = words.iter().filter(|(p, _, _)| *p == 1).collect();
1374        assert_eq!(computer[0], &(1, "コンピュータ", false));
1375        assert!(computer.iter().skip(1).all(|(_, _, v)| *v));
1376        assert!(computer.iter().any(|(_, t, _)| *t == "コン"));
1377        assert!(words.contains(&(2, "の", false)));
1378        assert!(words.contains(&(3, "研究", false)));
1379        // Queries emit the words only.
1380        let query = tokenizer.tokenize_with("量子コンピュータ", None, Purpose::Match);
1381        assert!(query.iter().all(|t| !t.variant));
1382        assert_eq!(query.len(), 2);
1383        // Thai is split into words by the LSTM model.
1384        let thai = tokenizer.tokenize("สวัสดีครับ");
1385        assert!(thai.len() >= 2);
1386        assert!(thai.iter().all(|t| !t.variant));
1387    }
1388
1389    #[test]
1390    fn unicode_and_simple_segmenters_keep_their_behaviour() {
1391        let unicode = lex("segmenter: unicode, stem: none");
1392        let tokens: Vec<String> = unicode
1393            .tokenize("Float-zero p53 日本語")
1394            .into_iter()
1395            .filter(|t| !t.variant)
1396            .map(|t| t.text)
1397            .collect();
1398        assert_eq!(tokens, vec!["float", "zero", "p53", "日本", "本語"]);
1399        let simple = lex("segmenter: simple, stem: none");
1400        let tokens: Vec<String> = simple
1401            .tokenize("Float-zero p53")
1402            .into_iter()
1403            .map(|t| t.text)
1404            .collect();
1405        assert_eq!(tokens, vec!["float", "zero", "p53"]);
1406    }
1407
1408    #[test]
1409    fn single_letter_words_survive_stop_words() {
1410        let tokenizer = lex("by: languages, default: en, stop_words: true");
1411        let words = |text: &str, hint: &str| {
1412            tokenizer
1413                .tokenize_with(text, Some(hint), Purpose::Index)
1414                .into_iter()
1415                .filter(|t| !t.variant)
1416                .map(|t| t.text)
1417                .collect::<Vec<_>>()
1418        };
1419        assert_eq!(
1420            words("vitamin D and T cells", "en"),
1421            ["vitamin", "d", "t", "cells"]
1422        );
1423        assert_eq!(words("the Y chromosome", "en"), ["y", "chromosome"]);
1424        assert_eq!(words("a cat", "en"), ["cat"]);
1425        assert_eq!(
1426            words("la vitamine D et l'homme", "fr"),
1427            ["vitamine", "d", "homme"]
1428        );
1429    }
1430
1431    #[test]
1432    fn long_tokens_are_dropped_but_keep_their_position() {
1433        let tokenizer = lex("stem: none, max_token_length: 8");
1434        let tokens = tokenizer.tokenize("short averyveryverylongtoken next");
1435        assert_eq!(
1436            texts(&tokens),
1437            vec![
1438                (0, "short".to_string(), false),
1439                (2, "next".to_string(), false)
1440            ]
1441        );
1442        let unlimited = lex("stem: none, max_token_length: 0");
1443        assert_eq!(unlimited.tokenize("averyveryverylongtoken").len(), 1);
1444        // Multibyte tokens are measured in characters.
1445        let cyrillic = lex("stem: none, max_token_length: 8");
1446        assert_eq!(cyrillic.tokenize("исследование").len(), 0);
1447        assert_eq!(cyrillic.tokenize("исследов").len(), 1);
1448    }
1449
1450    #[test]
1451    fn stem_modes_and_arabic_normalisation() {
1452        let word = "running";
1453        assert_eq!(
1454            lex("default: en, stem: none").tokenize(word)[0].text,
1455            "running"
1456        );
1457        assert_eq!(
1458            lex("default: en, stem: light").tokenize(word)[0].text,
1459            "running"
1460        );
1461        let snowball = lex("default: en, stem: snowball").tokenize(word);
1462        assert_eq!(
1463            texts(&snowball),
1464            vec![
1465                (0, "running".to_string(), false),
1466                (0, "run".to_string(), true)
1467            ]
1468        );
1469
1470        let arabic = lex("default: ar").tokenize("الْكِتَابُ");
1471        assert_eq!(arabic[0].text, "الكتاب");
1472        assert!(!arabic[0].variant);
1473        assert_eq!(arabic[1].text, "كتاب");
1474        assert!(arabic[1].variant);
1475    }
1476
1477    #[test]
1478    fn traditional_chinese_is_indexed_and_queried_as_simplified() {
1479        let tokenizer = lex("han: simplified");
1480        let words = |text: &str| -> Vec<String> {
1481            tokenizer
1482                .tokenize(text)
1483                .into_iter()
1484                .filter(|t| !t.variant)
1485                .map(|t| t.text)
1486                .collect()
1487        };
1488        assert_eq!(words("電腦網絡"), words("电脑网络"));
1489        assert!(words("電腦網絡").concat().contains("电脑"));
1490        let query: Vec<String> = tokenizer
1491            .tokenize_with("電腦", None, Purpose::Match)
1492            .into_iter()
1493            .map(|t| t.text)
1494            .collect();
1495        assert_eq!(query, vec!["电脑"]);
1496        assert_eq!(words("コンピュータ"), vec!["コンピュータ"]);
1497    }
1498
1499    #[test]
1500    fn spec_round_trips_and_renders_only_non_defaults() {
1501        let text = "lex(by: languages, default: en, stop_words: true, segmenter: unicode, stem: snowball, variants: false, fold: false, max_token_length: 32, han: simplified)";
1502        let spec = TokenizerSpec::parse(text).unwrap();
1503        assert_eq!(spec.to_string(), text);
1504        assert_eq!(spec.hint_field(), Some("languages"));
1505        assert!(!spec.keeps_original());
1506        let options = spec.lex().unwrap();
1507        assert_eq!(options.stem, StemMode::Snowball);
1508        assert_eq!(options.segmenter, Segmenter::Unicode);
1509        assert_eq!(options.han, HanForm::Simplified);
1510        assert_eq!(options.max_token_length, 32);
1511
1512        assert_eq!(TokenizerSpec::parse("lex()").unwrap().to_string(), "lex()");
1513        assert_eq!(
1514            TokenizerSpec::parse("lex(segmenter: icu, stem: light, variants: true, fold: true, max_token_length: 64, han: as_written, cjk: icu, default: none)")
1515                .unwrap()
1516                .to_string(),
1517            "lex()"
1518        );
1519        assert_eq!(
1520            TokenizerSpec::parse("lex(by:languages,default:english,stop_words:true)")
1521                .unwrap()
1522                .to_string(),
1523            "lex(by: languages, default: en, stop_words: true)"
1524        );
1525        assert_eq!(
1526            TokenizerSpec::parse("en_stem").unwrap(),
1527            TokenizerSpec::Named("en_stem".to_string())
1528        );
1529        for bad in [
1530            "lex(stem: aggressive)",
1531            "lex(max_token_length: many)",
1532            "lex(by: )",
1533            "lex(default: klingon)",
1534            "lex(segmenter: nope)",
1535            "lex(han: traditional)",
1536            "lex(colour: red)",
1537            "lex(by: lang",
1538            "en_stem(foo)",
1539            "",
1540        ] {
1541            assert!(TokenizerSpec::parse(bad).is_err(), "{bad}");
1542        }
1543        // A spec without `by` ignores hints.
1544        let fixed = lex("default: en, stem: snowball, variants: false");
1545        let ru = fixed.tokenize_with("running", Some("ru"), Purpose::Match);
1546        assert_eq!(ru[0].text, "run");
1547        assert_eq!(
1548            TokenizerSpec::parse("lex(cjk: dictionary)").is_ok(),
1549            cjk_morph::available()
1550        );
1551    }
1552
1553    #[cfg(feature = "cjk-dict")]
1554    #[test]
1555    fn dictionary_morphology_for_japanese_and_korean() {
1556        let tokenizer = lex("by: languages, default: en, cjk: dictionary, han: simplified");
1557        // Japanese needs the `ja` hint for Han runs; kana runs always go
1558        // through the dictionary.
1559        let ja = tokenizer.tokenize_with("研究を食べました", Some("ja"), Purpose::Index);
1560        assert_eq!(
1561            texts(&ja),
1562            vec![
1563                (0, "研究".to_string(), false),
1564                (2, "食べ".to_string(), false),
1565                (2, "食べる".to_string(), true),
1566            ]
1567        );
1568        let matched = tokenizer.tokenize_with("食べました", Some("ja"), Purpose::Match);
1569        assert_eq!(texts(&matched), vec![(0, "食べる".to_string(), false)]);
1570        let exact = tokenizer.tokenize_with("食べました", Some("ja"), Purpose::Exact);
1571        assert_eq!(texts(&exact), vec![(0, "食べ".to_string(), false)]);
1572        // A Japanese surface with a traditional form gains the simplified
1573        // variant, so a hint-less Han query (ICU + folding) still matches.
1574        let learning = tokenizer.tokenize_with("學校", Some("ja"), Purpose::Index);
1575        assert!(learning.iter().any(|t| t.variant && t.text == "学校"));
1576
1577        // Korean needs no hint: particles and endings keep their positions.
1578        let ko = tokenizer.tokenize("학교에서 친구들과 공부했습니다");
1579        assert_eq!(
1580            texts(&ko),
1581            vec![
1582                (0, "학교".to_string(), false),
1583                (2, "친구".to_string(), false),
1584                (5, "공부".to_string(), false),
1585            ]
1586        );
1587        // Mixed text: Latin words still go through ICU and the stemmers.
1588        let mixed = tokenizer.tokenize_with("cells 학교에서", Some("en"), Purpose::Index);
1589        assert_eq!(
1590            texts(&mixed),
1591            vec![
1592                (0, "cells".to_string(), false),
1593                (0, "cell".to_string(), true),
1594                (1, "학교".to_string(), false),
1595            ]
1596        );
1597    }
1598}