use std::collections::{HashMap, HashSet};
use parking_lot::RwLock;
use super::{
Language, Purpose, Script, Token, Tokenizer, cjk_morph, clean_word, language_code, light_stem,
parse_language_opt, split_whitespace_with_offsets, with_stemmers,
};
pub const DEFAULT_MAX_TOKEN_LENGTH: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Segmenter {
#[default]
Icu,
Unicode,
Simple,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StemMode {
#[default]
Light,
Snowball,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HanForm {
#[default]
AsWritten,
Simplified,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CjkMode {
#[default]
Icu,
Dictionary,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LexOptions {
pub by: Option<String>,
pub default: Option<Language>,
pub stop_words: bool,
pub segmenter: Segmenter,
pub stem: StemMode,
pub variants: bool,
pub fold: bool,
pub max_token_length: usize,
pub han: HanForm,
pub cjk: CjkMode,
}
impl Default for LexOptions {
fn default() -> Self {
Self {
by: None,
default: None,
stop_words: false,
segmenter: Segmenter::Icu,
stem: StemMode::Light,
variants: true,
fold: true,
max_token_length: DEFAULT_MAX_TOKEN_LENGTH,
han: HanForm::AsWritten,
cjk: CjkMode::Icu,
}
}
}
impl LexOptions {
pub fn parse(params: &str) -> Result<Self, String> {
let mut options = Self::default();
let spec = format!("lex({params})");
let parse_bool = |key: &str, value: &str| -> Result<bool, String> {
match value {
"true" => Ok(true),
"false" => Ok(false),
other => Err(format!(
"tokenizer spec '{spec}': '{key}' must be true or false, got '{other}'"
)),
}
};
let choice = |key: &str, value: &str, allowed: &[&str]| -> Result<(), String> {
if allowed.contains(&value) {
Ok(())
} else {
Err(format!(
"tokenizer spec '{spec}': '{key}' must be one of {}, got '{value}'",
allowed.join(", ")
))
}
};
for param in params.split(',') {
let param = param.trim();
if param.is_empty() {
continue;
}
let Some((key, value)) = param.split_once(':') else {
return Err(format!(
"tokenizer spec '{spec}': parameter '{param}' must be 'key: value'"
));
};
let (key, value) = (key.trim(), value.trim());
match key {
"by" if !value.is_empty() => options.by = Some(value.to_string()),
"by" => return Err(format!("tokenizer spec '{spec}': 'by' needs a field name")),
"default" => {
options.default = match value {
"none" => None,
other => Some(parse_language_opt(other).ok_or_else(|| {
format!("tokenizer spec '{spec}': unknown default language '{other}'")
})?),
};
}
"stop_words" => options.stop_words = parse_bool(key, value)?,
"variants" => options.variants = parse_bool(key, value)?,
"fold" => options.fold = parse_bool(key, value)?,
"segmenter" => {
choice(key, value, &["icu", "unicode", "simple"])?;
options.segmenter = match value {
"icu" => Segmenter::Icu,
"unicode" => Segmenter::Unicode,
_ => Segmenter::Simple,
};
}
"stem" => {
choice(key, value, &["light", "snowball", "none"])?;
options.stem = match value {
"light" => StemMode::Light,
"snowball" => StemMode::Snowball,
_ => StemMode::None,
};
}
"han" => {
choice(key, value, &["as_written", "simplified"])?;
options.han = if value == "simplified" {
HanForm::Simplified
} else {
HanForm::AsWritten
};
}
"cjk" => {
choice(key, value, &["icu", "dictionary"])?;
options.cjk = if value == "dictionary" {
if !cjk_morph::available() {
return Err(format!(
"tokenizer spec '{spec}': 'cjk: dictionary' needs a build with the cjk-dict feature (Japanese and Korean dictionaries)"
));
}
CjkMode::Dictionary
} else {
CjkMode::Icu
};
}
"max_token_length" => {
options.max_token_length = value.parse::<usize>().map_err(|_| {
format!(
"tokenizer spec '{spec}': 'max_token_length' must be a number, got '{value}'"
)
})?;
}
other => {
return Err(format!(
"tokenizer spec '{spec}': unknown parameter '{other}'"
));
}
}
}
Ok(options)
}
fn render(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let defaults = Self::default();
let mut parts: Vec<String> = Vec::new();
if let Some(by) = &self.by {
parts.push(format!("by: {by}"));
}
if let Some(language) = self.default {
parts.push(format!("default: {}", language_code(language)));
}
if self.stop_words != defaults.stop_words {
parts.push(format!("stop_words: {}", self.stop_words));
}
if self.segmenter != defaults.segmenter {
parts.push(format!(
"segmenter: {}",
match self.segmenter {
Segmenter::Icu => "icu",
Segmenter::Unicode => "unicode",
Segmenter::Simple => "simple",
}
));
}
if self.stem != defaults.stem {
parts.push(format!(
"stem: {}",
match self.stem {
StemMode::Light => "light",
StemMode::Snowball => "snowball",
StemMode::None => "none",
}
));
}
if self.variants != defaults.variants {
parts.push(format!("variants: {}", self.variants));
}
if self.fold != defaults.fold {
parts.push(format!("fold: {}", self.fold));
}
if self.max_token_length != defaults.max_token_length {
parts.push(format!("max_token_length: {}", self.max_token_length));
}
if self.han != defaults.han {
parts.push("han: simplified".to_string());
}
if self.cjk != defaults.cjk {
parts.push("cjk: dictionary".to_string());
}
write!(f, "lex({})", parts.join(", "))
}
}
impl std::fmt::Display for LexOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.render(f)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TokenizerSpec {
Named(String),
Lex(LexOptions),
}
impl TokenizerSpec {
pub fn parse(spec: &str) -> Result<TokenizerSpec, String> {
let spec = spec.trim();
let Some(rest) = spec.strip_prefix("lex(") else {
if spec.is_empty() || spec.contains(['(', ')', ':', ',']) {
return Err(format!("invalid tokenizer spec '{spec}'"));
}
return Ok(TokenizerSpec::Named(spec.to_string()));
};
let Some(params) = rest.strip_suffix(')') else {
return Err(format!("tokenizer spec '{spec}' is missing ')'"));
};
LexOptions::parse(params).map(TokenizerSpec::Lex)
}
pub fn lex(&self) -> Option<&LexOptions> {
match self {
TokenizerSpec::Named(_) => None,
TokenizerSpec::Lex(options) => Some(options),
}
}
pub fn hint_field(&self) -> Option<&str> {
self.lex().and_then(|options| options.by.as_deref())
}
pub fn keeps_original(&self) -> bool {
self.lex().is_some_and(|options| options.variants)
}
pub fn dynamic_tokenizer(&self) -> Option<super::BoxedTokenizer> {
self.lex()
.map(|options| Box::new(LexTokenizer::new(options.clone())) as super::BoxedTokenizer)
}
}
impl std::fmt::Display for TokenizerSpec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TokenizerSpec::Named(name) => f.write_str(name),
TokenizerSpec::Lex(options) => options.render(f),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct LexTokenizer {
options: LexOptions,
}
impl LexTokenizer {
pub fn new(options: LexOptions) -> Self {
Self { options }
}
pub fn options(&self) -> &LexOptions {
&self.options
}
fn hints(&self, hint: Option<&str>) -> Hints {
let mut hints = Hints::default();
if self.options.by.is_some()
&& let Some(hint) = hint.map(str::trim).filter(|hint| !hint.is_empty())
{
for part in hint.split(',') {
let part = part.trim();
match part.to_ascii_lowercase().as_str() {
"ja" | "jpn" | "japanese" => hints.japanese = true,
"ko" | "kor" | "korean" => hints.korean = true,
_ => {
if let Some(language) = parse_language_opt(part)
&& !hints.languages.contains(&language)
{
hints.languages.push(language);
}
}
}
}
}
if hints.languages.is_empty() {
hints.languages.extend(self.options.default);
}
hints
}
fn run(&self, text: &str, hints: &Hints, purpose: Purpose) -> Vec<Token> {
let stops: Vec<Option<&'static HashSet<String>>> = hints
.languages
.iter()
.map(|language| {
self.options
.stop_words
.then(|| stop_word_set(*language))
.flatten()
})
.collect();
if hints.languages.is_empty() || self.options.stem != StemMode::Snowball {
self.walk(text, &Ctx::new(hints, &stops, &[]), purpose)
} else {
with_stemmers(&hints.languages, |stemmers| {
self.walk(text, &Ctx::new(hints, &stops, stemmers), purpose)
})
}
}
fn walk(&self, text: &str, ctx: &Ctx<'_>, purpose: Purpose) -> Vec<Token> {
let mut emitter = Emitter {
options: &self.options,
ctx,
purpose,
tokens: Vec::with_capacity(text.len() / 5),
position: 0,
run: Vec::new(),
run_end: 0,
};
match self.options.segmenter {
Segmenter::Simple => {
for (offset, word) in split_whitespace_with_offsets(text) {
emitter.word(offset, word);
}
}
Segmenter::Unicode => {
use unicode_segmentation::UnicodeSegmentation;
for (offset, word) in text.unicode_word_indices() {
if word.chars().all(|c| CjkScript::of(c).is_cjk()) {
emitter.cjk_chars(offset, word);
} else {
emitter.word(offset, word);
}
}
}
Segmenter::Icu if self.options.cjk == CjkMode::Dictionary => {
for (start, end, kind) in morph_spans(text, ctx.hints) {
match kind {
SpanKind::Japanese => {
emitter.morph_run(start, &text[start..end], cjk_morph::japanese)
}
SpanKind::Korean => {
emitter.morph_run(start, &text[start..end], cjk_morph::korean)
}
SpanKind::Icu => emitter.icu_span(start, &text[start..end]),
}
}
}
Segmenter::Icu => emitter.icu_span(0, text),
}
emitter.flush_run();
emitter.tokens
}
}
impl Tokenizer for LexTokenizer {
fn tokenize(&self, text: &str) -> Vec<Token> {
self.run(text, &self.hints(None), Purpose::Index)
}
fn tokenize_with(&self, text: &str, hint: Option<&str>, purpose: Purpose) -> Vec<Token> {
self.run(text, &self.hints(hint), purpose)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct Hints {
languages: Vec<Language>,
japanese: bool,
korean: bool,
}
struct Ctx<'a> {
hints: &'a Hints,
stops: &'a [Option<&'static HashSet<String>>],
stemmers: &'a [&'a rust_stemmers::Stemmer],
}
impl<'a> Ctx<'a> {
fn new(
hints: &'a Hints,
stops: &'a [Option<&'static HashSet<String>>],
stemmers: &'a [&'a rust_stemmers::Stemmer],
) -> Self {
Self {
hints,
stops,
stemmers,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CjkScript {
Han,
Kana,
Hangul,
Other,
}
impl CjkScript {
#[inline]
fn of(c: char) -> Self {
match c as u32 {
0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x20000..=0x2FA1F => Self::Han,
0x3040..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9F => Self::Kana,
0xAC00..=0xD7AF
| 0x1100..=0x11FF
| 0x3130..=0x318F
| 0xA960..=0xA97F
| 0xD7B0..=0xD7FF => Self::Hangul,
_ => Self::Other,
}
}
#[inline]
fn is_cjk(self) -> bool {
matches!(self, Self::Han | Self::Kana)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SpanKind {
Japanese,
Korean,
Icu,
}
fn morph_spans(text: &str, hints: &Hints) -> Vec<(usize, usize, SpanKind)> {
let classify = |c: char| match CjkScript::of(c) {
CjkScript::Hangul => SpanKind::Korean,
CjkScript::Kana => SpanKind::Japanese,
CjkScript::Han if hints.japanese => SpanKind::Japanese,
_ => SpanKind::Icu,
};
let mut spans: Vec<(usize, usize, SpanKind)> = Vec::new();
for (offset, c) in text.char_indices() {
let kind = classify(c);
let end = offset + c.len_utf8();
match spans.last_mut() {
Some((_, last_end, last_kind)) if *last_kind == kind && *last_end == offset => {
*last_end = end;
}
_ => spans.push((offset, end, kind)),
}
}
spans
}
struct Emitter<'a> {
options: &'a LexOptions,
ctx: &'a Ctx<'a>,
purpose: Purpose,
tokens: Vec<Token>,
position: u32,
run: Vec<(usize, char)>,
run_end: usize,
}
impl Emitter<'_> {
fn icu_span(&mut self, base: usize, text: &str) {
let segmenter = icu_word_segmenter();
let mut start = 0usize;
for (end, kind) in segmenter.segment_str(text).iter_with_word_type() {
let segment = &text[start..end];
let offset = base + start;
start = end;
if !kind.is_word_like() {
continue;
}
if segment.chars().all(|c| CjkScript::of(c).is_cjk()) {
self.cjk_word(offset, segment);
} else {
self.word(offset, segment);
}
}
}
fn word(&mut self, offset: usize, raw: &str) {
self.flush_run();
if raw.is_empty() {
return;
}
let cleaned = if self.options.segmenter == Segmenter::Simple || raw.is_ascii() {
clean_word(raw)
} else {
use unicode_normalization::UnicodeNormalization;
let normalized: String = raw.nfkc().collect();
clean_word(&normalized)
};
if !cleaned.is_empty() {
self.emit_word(cleaned, offset, offset + raw.len());
}
}
fn cjk_chars(&mut self, offset: usize, word: &str) {
if !self.run.is_empty() && offset != self.run_end {
self.flush_run();
}
let mut at = offset;
for c in word.chars() {
self.run.push((at, c));
at += c.len_utf8();
}
self.run_end = at;
}
fn cjk_word(&mut self, offset: usize, word: &str) {
if word.chars().nth(1).is_none() {
self.cjk_chars(offset, word);
return;
}
self.flush_run();
use unicode_normalization::UnicodeNormalization;
let text = self.simplify(word.nfkc().collect());
let end = offset + word.len();
let position = self.position;
self.tokens
.push(Token::new(text.clone(), position, offset, end));
if self.purpose == Purpose::Index {
self.push_bigram_variants(&text, position, offset, end);
}
self.position += 1;
}
fn push_bigram_variants(&mut self, text: &str, position: u32, from: usize, to: usize) {
let chars: Vec<char> = text.chars().collect();
if chars.len() < 3 || !chars.iter().all(|c| CjkScript::of(*c).is_cjk()) {
return;
}
for pair in chars.windows(2) {
let mut bigram = String::with_capacity(8);
bigram.push(pair[0]);
bigram.push(pair[1]);
self.tokens
.push(Token::variant_of(bigram, position, from, to));
}
}
fn flush_run(&mut self) {
match self.run.len() {
0 => {}
1 => {
let (offset, c) = self.run[0];
let text = self.simplify(c.to_string());
self.tokens.push(Token::new(
text,
self.position,
offset,
offset + c.len_utf8(),
));
self.position += 1;
}
_ => {
for pair in self.run.windows(2) {
let (start, a) = pair[0];
let (next, b) = pair[1];
let mut text = String::with_capacity(8);
text.push(a);
text.push(b);
let text = self.simplify(text);
self.tokens
.push(Token::new(text, self.position, start, next + b.len_utf8()));
self.position += 1;
}
}
}
self.run.clear();
}
fn simplify(&self, text: String) -> String {
if self.options.han == HanForm::Simplified
&& text.chars().any(|c| CjkScript::of(c) == CjkScript::Han)
{
han_to_simplified(&text)
} else {
text
}
}
fn too_long(&self, text: &str) -> bool {
let max = self.options.max_token_length;
max > 0 && text.len() > max && text.chars().count() > max
}
fn morph_run(&mut self, base: usize, text: &str, analyse: fn(&str) -> Vec<cjk_morph::Morph>) {
self.flush_run();
for morph in analyse(text) {
if !morph.content {
self.position += 1;
continue;
}
use unicode_normalization::UnicodeNormalization;
let surface: String = morph.surface.nfkc().collect();
if self.too_long(&surface) {
self.position += 1;
continue;
}
let (from, to) = (base + morph.start, base + morph.end);
let position = self.position;
match self.purpose {
Purpose::Index => {
let start = self.tokens.len();
self.tokens
.push(Token::new(surface.clone(), position, from, to));
if let Some(lemma) = morph.lemma {
self.push_variant(start, lemma, position, from, to);
}
let simplified = self.simplify(surface.clone());
self.push_variant(start, simplified, position, from, to);
self.push_bigram_variants(&surface, position, from, to);
}
Purpose::Match => {
let form = morph.lemma.unwrap_or(surface);
self.tokens.push(Token::new(form, position, from, to));
}
Purpose::Exact => {
self.tokens.push(Token::new(surface, position, from, to));
}
}
self.position += 1;
}
}
fn push_variant(&mut self, start: usize, text: String, position: u32, from: usize, to: usize) {
if self.tokens[start..].iter().any(|t| t.text == text) {
return;
}
self.tokens
.push(Token::variant_of(text, position, from, to));
}
fn emit_word(&mut self, word: String, from: usize, to: usize) {
let options = self.options;
let script = Script::of_token(&word);
let route = self
.ctx
.hints
.languages
.iter()
.position(|language| language.script() == script);
let word = match script {
Script::Arabic => light_stem::arabic_normalize(&word).unwrap_or(word),
Script::Cyrillic if word.contains('ё') => word.replace('ё', "е"),
_ => word,
};
if let Some(index) = route
&& self.ctx.stops[index].is_some_and(|set| set.contains(word.as_str()))
{
self.position += 1;
return;
}
if self.too_long(&word) {
self.position += 1;
return;
}
let stem: Option<String> = route.and_then(|index| match options.stem {
StemMode::None => None,
StemMode::Light => light_stem::light_stem(self.ctx.hints.languages[index], &word),
StemMode::Snowball => {
let stemmer = self.ctx.stemmers.get(index)?;
match stemmer.stem(&word) {
std::borrow::Cow::Borrowed(_) => None,
std::borrow::Cow::Owned(stemmed) => (stemmed != word).then_some(stemmed),
}
}
});
let position = self.position;
match self.purpose {
Purpose::Index if options.variants => {
let start = self.tokens.len();
let folded = options.fold.then(|| fold_diacritics(&word)).flatten();
let folded_stem = options
.fold
.then(|| stem.as_deref().and_then(fold_diacritics))
.flatten();
self.tokens.push(Token::new(word, position, from, to));
for variant in [stem, folded, folded_stem].into_iter().flatten() {
self.push_variant(start, variant, position, from, to);
}
}
Purpose::Exact if options.variants => {
self.tokens.push(Token::new(word, position, from, to));
}
Purpose::Index | Purpose::Match | Purpose::Exact => {
let base = stem.unwrap_or(word);
let out = if options.fold && !options.variants {
fold_diacritics(&base).unwrap_or(base)
} else {
base
};
self.tokens.push(Token::new(out, position, from, to));
}
}
self.position += 1;
}
}
fn han_to_simplified(text: &str) -> String {
text.chars()
.map(|c| super::han_t2s::to_simplified(c).unwrap_or(c))
.collect()
}
fn fold_diacritics(word: &str) -> Option<String> {
if word.is_ascii() {
return None;
}
if !matches!(
Script::of_token(word),
Script::Latin | Script::Cyrillic | Script::Greek
) {
return None;
}
use unicode_normalization::UnicodeNormalization;
use unicode_normalization::char::is_combining_mark;
let folded: String = word
.nfkd()
.filter(|c| !is_combining_mark(*c))
.flat_map(|c| c.to_lowercase())
.collect();
(folded != word).then_some(folded)
}
fn icu_word_segmenter() -> &'static icu_segmenter::WordSegmenterBorrowed<'static> {
static SEGMENTER: std::sync::OnceLock<icu_segmenter::WordSegmenterBorrowed<'static>> =
std::sync::OnceLock::new();
SEGMENTER.get_or_init(|| {
icu_segmenter::WordSegmenter::new_auto(
icu_segmenter::options::WordBreakInvariantOptions::default(),
)
})
}
fn stop_word_set(language: Language) -> Option<&'static HashSet<String>> {
static SETS: std::sync::OnceLock<RwLock<HashMap<Language, &'static HashSet<String>>>> =
std::sync::OnceLock::new();
let sets = SETS.get_or_init(|| RwLock::new(HashMap::new()));
if let Some(set) = sets.read().get(&language) {
return Some(set);
}
let set: &'static HashSet<String> = Box::leak(Box::new(
stop_words::get(language.to_stop_words_language())
.iter()
.map(|word| word.to_string())
.collect(),
));
Some(*sets.write().entry(language).or_insert(set))
}
#[cfg(test)]
mod tests {
use super::*;
fn texts(tokens: &[Token]) -> Vec<(u32, String, bool)> {
tokens
.iter()
.map(|t| (t.position, t.text.clone(), t.variant))
.collect()
}
fn lex(spec: &str) -> LexTokenizer {
LexTokenizer::new(LexOptions::parse(spec).unwrap())
}
#[test]
fn variants_index_stem_and_folded_forms_next_to_the_written_word() {
let tokenizer = lex("by: languages, default: en, stop_words: true");
let tokens = tokenizer.tokenize("The cell membranes of résumés");
assert_eq!(
texts(&tokens),
vec![
(1, "cell".to_string(), false),
(2, "membranes".to_string(), false),
(2, "membrane".to_string(), true),
(4, "résumés".to_string(), false),
(4, "résumé".to_string(), true),
(4, "resumes".to_string(), true),
(4, "resume".to_string(), true),
]
);
let matched = tokenizer.tokenize_with("cell membranes", Some("en"), Purpose::Match);
assert_eq!(
texts(&matched),
vec![
(0, "cell".to_string(), false),
(1, "membrane".to_string(), false)
]
);
let exact = tokenizer.tokenize_with("cell membranes", Some("en"), Purpose::Exact);
assert_eq!(
texts(&exact),
vec![
(0, "cell".to_string(), false),
(1, "membranes".to_string(), false)
]
);
let fallback = tokenizer.tokenize_with("membranes", Some("xx"), Purpose::Match);
assert_eq!(fallback[0].text, "membrane");
let none = lex("").tokenize_with("membranes", None, Purpose::Match);
assert_eq!(none[0].text, "membranes");
}
#[test]
fn without_variants_the_folded_stem_replaces_the_word_for_every_purpose() {
let tokenizer = lex("default: en, stem: snowball, variants: false");
for purpose in [Purpose::Index, Purpose::Match, Purpose::Exact] {
let tokens = tokenizer.tokenize_with("Running cafés", None, purpose);
assert_eq!(
texts(&tokens),
vec![
(0, "run".to_string(), false),
(1, "cafe".to_string(), false)
],
"{purpose:?}"
);
}
}
#[test]
fn icu_segments_cjk_words_with_bigram_variants_and_thai() {
let tokenizer = lex("");
let tokens = tokenizer.tokenize("量子コンピュータの研究");
let words: Vec<(u32, &str, bool)> = tokens
.iter()
.map(|t| (t.position, t.text.as_str(), t.variant))
.collect();
assert_eq!(words[0], (0, "量子", false));
let computer: Vec<&(u32, &str, bool)> = words.iter().filter(|(p, _, _)| *p == 1).collect();
assert_eq!(computer[0], &(1, "コンピュータ", false));
assert!(computer.iter().skip(1).all(|(_, _, v)| *v));
assert!(computer.iter().any(|(_, t, _)| *t == "コン"));
assert!(words.contains(&(2, "の", false)));
assert!(words.contains(&(3, "研究", false)));
let query = tokenizer.tokenize_with("量子コンピュータ", None, Purpose::Match);
assert!(query.iter().all(|t| !t.variant));
assert_eq!(query.len(), 2);
let thai = tokenizer.tokenize("สวัสดีครับ");
assert!(thai.len() >= 2);
assert!(thai.iter().all(|t| !t.variant));
}
#[test]
fn unicode_and_simple_segmenters_keep_their_behaviour() {
let unicode = lex("segmenter: unicode, stem: none");
let tokens: Vec<String> = unicode
.tokenize("Float-zero p53 日本語")
.into_iter()
.filter(|t| !t.variant)
.map(|t| t.text)
.collect();
assert_eq!(tokens, vec!["float", "zero", "p53", "日本", "本語"]);
let simple = lex("segmenter: simple, stem: none");
let tokens: Vec<String> = simple
.tokenize("Float-zero p53")
.into_iter()
.map(|t| t.text)
.collect();
assert_eq!(tokens, vec!["floatzero", "p53"]);
}
#[test]
fn long_tokens_are_dropped_but_keep_their_position() {
let tokenizer = lex("stem: none, max_token_length: 8");
let tokens = tokenizer.tokenize("short averyveryverylongtoken next");
assert_eq!(
texts(&tokens),
vec![
(0, "short".to_string(), false),
(2, "next".to_string(), false)
]
);
let unlimited = lex("stem: none, max_token_length: 0");
assert_eq!(unlimited.tokenize("averyveryverylongtoken").len(), 1);
let cyrillic = lex("stem: none, max_token_length: 8");
assert_eq!(cyrillic.tokenize("исследование").len(), 0);
assert_eq!(cyrillic.tokenize("исследов").len(), 1);
}
#[test]
fn stem_modes_and_arabic_normalisation() {
let word = "running";
assert_eq!(
lex("default: en, stem: none").tokenize(word)[0].text,
"running"
);
assert_eq!(
lex("default: en, stem: light").tokenize(word)[0].text,
"running"
);
let snowball = lex("default: en, stem: snowball").tokenize(word);
assert_eq!(
texts(&snowball),
vec![
(0, "running".to_string(), false),
(0, "run".to_string(), true)
]
);
let arabic = lex("default: ar").tokenize("الْكِتَابُ");
assert_eq!(arabic[0].text, "الكتاب");
assert!(!arabic[0].variant);
assert_eq!(arabic[1].text, "كتاب");
assert!(arabic[1].variant);
}
#[test]
fn traditional_chinese_is_indexed_and_queried_as_simplified() {
let tokenizer = lex("han: simplified");
let words = |text: &str| -> Vec<String> {
tokenizer
.tokenize(text)
.into_iter()
.filter(|t| !t.variant)
.map(|t| t.text)
.collect()
};
assert_eq!(words("電腦網絡"), words("电脑网络"));
assert!(words("電腦網絡").concat().contains("电脑"));
let query: Vec<String> = tokenizer
.tokenize_with("電腦", None, Purpose::Match)
.into_iter()
.map(|t| t.text)
.collect();
assert_eq!(query, vec!["电脑"]);
assert_eq!(words("コンピュータ"), vec!["コンピュータ"]);
}
#[test]
fn spec_round_trips_and_renders_only_non_defaults() {
let text = "lex(by: languages, default: en, stop_words: true, segmenter: unicode, stem: snowball, variants: false, fold: false, max_token_length: 32, han: simplified)";
let spec = TokenizerSpec::parse(text).unwrap();
assert_eq!(spec.to_string(), text);
assert_eq!(spec.hint_field(), Some("languages"));
assert!(!spec.keeps_original());
let options = spec.lex().unwrap();
assert_eq!(options.stem, StemMode::Snowball);
assert_eq!(options.segmenter, Segmenter::Unicode);
assert_eq!(options.han, HanForm::Simplified);
assert_eq!(options.max_token_length, 32);
assert_eq!(TokenizerSpec::parse("lex()").unwrap().to_string(), "lex()");
assert_eq!(
TokenizerSpec::parse("lex(segmenter: icu, stem: light, variants: true, fold: true, max_token_length: 64, han: as_written, cjk: icu, default: none)")
.unwrap()
.to_string(),
"lex()"
);
assert_eq!(
TokenizerSpec::parse("lex(by:languages,default:english,stop_words:true)")
.unwrap()
.to_string(),
"lex(by: languages, default: en, stop_words: true)"
);
assert_eq!(
TokenizerSpec::parse("en_stem").unwrap(),
TokenizerSpec::Named("en_stem".to_string())
);
for bad in [
"lex(stem: aggressive)",
"lex(max_token_length: many)",
"lex(by: )",
"lex(default: klingon)",
"lex(segmenter: nope)",
"lex(han: traditional)",
"lex(colour: red)",
"lex(by: lang",
"en_stem(foo)",
"",
] {
assert!(TokenizerSpec::parse(bad).is_err(), "{bad}");
}
let fixed = lex("default: en, stem: snowball, variants: false");
let ru = fixed.tokenize_with("running", Some("ru"), Purpose::Match);
assert_eq!(ru[0].text, "run");
assert_eq!(
TokenizerSpec::parse("lex(cjk: dictionary)").is_ok(),
cjk_morph::available()
);
}
#[cfg(feature = "cjk-dict")]
#[test]
fn dictionary_morphology_for_japanese_and_korean() {
let tokenizer = lex("by: languages, default: en, cjk: dictionary, han: simplified");
let ja = tokenizer.tokenize_with("研究を食べました", Some("ja"), Purpose::Index);
assert_eq!(
texts(&ja),
vec![
(0, "研究".to_string(), false),
(2, "食べ".to_string(), false),
(2, "食べる".to_string(), true),
]
);
let matched = tokenizer.tokenize_with("食べました", Some("ja"), Purpose::Match);
assert_eq!(texts(&matched), vec![(0, "食べる".to_string(), false)]);
let exact = tokenizer.tokenize_with("食べました", Some("ja"), Purpose::Exact);
assert_eq!(texts(&exact), vec![(0, "食べ".to_string(), false)]);
let learning = tokenizer.tokenize_with("學校", Some("ja"), Purpose::Index);
assert!(learning.iter().any(|t| t.variant && t.text == "学校"));
let ko = tokenizer.tokenize("학교에서 친구들과 공부했습니다");
assert_eq!(
texts(&ko),
vec![
(0, "학교".to_string(), false),
(2, "친구".to_string(), false),
(5, "공부".to_string(), false),
]
);
let mixed = tokenizer.tokenize_with("cells 학교에서", Some("en"), Purpose::Index);
assert_eq!(
texts(&mixed),
vec![
(0, "cells".to_string(), false),
(0, "cell".to_string(), true),
(1, "학교".to_string(), false),
]
);
}
}