1use 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
60pub const DEFAULT_MAX_TOKEN_LENGTH: usize = 64;
63
64const SEGMENT_WINDOW_SOFT: usize = 1024;
73const SEGMENT_WINDOW_HARD: usize = 4096;
74
75fn 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
97fn split_word(segment: &str) -> Vec<(usize, usize, String)> {
118 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 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 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
187fn 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
211const CONTRACTION_SUFFIXES: &[&str] = &["s", "t", "re", "ve", "ll", "d", "m"];
213
214const 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
221fn 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 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
246fn 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
258fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
289pub enum Segmenter {
290 #[default]
294 Icu,
295 Unicode,
299 Simple,
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
306pub enum StemMode {
307 #[default]
309 Light,
310 Snowball,
312 None,
314}
315
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
318pub enum HanForm {
319 #[default]
321 AsWritten,
322 Simplified,
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
330pub enum CjkMode {
331 #[default]
334 Icu,
335 Dictionary,
339}
340
341#[derive(Debug, Clone, PartialEq, Eq)]
344pub struct LexOptions {
345 pub by: Option<String>,
348 pub default: Option<Language>,
351 pub stop_words: bool,
353 pub segmenter: Segmenter,
354 pub stem: StemMode,
355 pub variants: bool,
358 pub fold: bool,
360 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
549pub enum TokenizerSpec {
550 Named(String),
551 Lex(LexOptions),
552}
553
554impl TokenizerSpec {
555 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 pub fn lex(&self) -> Option<&LexOptions> {
572 match self {
573 TokenizerSpec::Named(_) => None,
574 TokenizerSpec::Lex(options) => Some(options),
575 }
576 }
577
578 pub fn hint_field(&self) -> Option<&str> {
580 self.lex().and_then(|options| options.by.as_deref())
581 }
582
583 pub fn keeps_original(&self) -> bool {
587 self.lex().is_some_and(|options| options.variants)
588 }
589
590 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#[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 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
733struct Hints {
734 languages: Vec<Language>,
735 japanese: bool,
736 korean: bool,
737}
738
739struct Ctx<'a> {
741 hints: &'a Hints,
742 stops: &'a [Option<&'static HashSet<String>>],
743 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#[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 #[inline]
788 fn is_cjk(self) -> bool {
789 matches!(self, Self::Han | Self::Kana)
790 }
791}
792
793#[derive(Debug, Clone, Copy, PartialEq, Eq)]
795enum SpanKind {
796 Japanese,
797 Korean,
798 Icu,
799}
800
801fn 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 run: Vec<(usize, char)>,
834 run_end: usize,
835}
836
837impl Emitter<'_> {
838 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 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 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 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 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 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 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 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 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 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 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 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 self.tokens.push(Token::new(word, position, from, to));
1096 }
1097 Purpose::Index | Purpose::Match | Purpose::Exact => {
1098 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
1113fn 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
1121fn 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
1145fn 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
1156fn 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 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 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 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 let fallback = tokenizer.tokenize_with("membranes", Some("xx"), Purpose::Match);
1341 assert_eq!(fallback[0].text, "membrane");
1342 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 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 let query = tokenizer.tokenize_with("量子コンピュータ", None, Purpose::Match);
1381 assert!(query.iter().all(|t| !t.variant));
1382 assert_eq!(query.len(), 2);
1383 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 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 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 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 let learning = tokenizer.tokenize_with("學校", Some("ja"), Purpose::Index);
1575 assert!(learning.iter().any(|t| t.variant && t.text == "学校"));
1576
1577 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 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}