#[cfg(any(feature = "native", feature = "wasm"))]
mod hf_tokenizer;
mod cjk_morph;
mod han_t2s;
#[cfg(feature = "native")]
mod idf_weights;
mod lex;
pub mod light_stem;
pub use cjk_morph::{available as cjk_dictionaries_available, warm_up as warm_up_cjk_dictionaries};
pub use lex::{
CjkMode, DEFAULT_MAX_TOKEN_LENGTH, HanForm, LexOptions, LexTokenizer, Segmenter, StemMode,
TokenizerSpec,
};
#[cfg(any(feature = "native", feature = "wasm"))]
pub use hf_tokenizer::{HfTokenizer, TokenizerSource};
#[cfg(feature = "native")]
pub use hf_tokenizer::{TokenizerCache, tokenizer_cache};
#[cfg(feature = "native")]
pub use idf_weights::{IdfWeights, IdfWeightsCache, idf_weights_cache};
use std::collections::HashMap;
use std::sync::Arc;
use parking_lot::RwLock;
use rust_stemmers::Algorithm;
use serde::{Deserialize, Serialize};
use stop_words::LANGUAGE;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Purpose {
Index,
Match,
Exact,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Token {
pub text: String,
pub position: u32,
pub offset_from: usize,
pub offset_to: usize,
pub variant: bool,
}
impl Token {
pub fn variant_of(text: String, position: u32, offset_from: usize, offset_to: usize) -> Self {
Self {
text,
position,
offset_from,
offset_to,
variant: true,
}
}
pub fn new(text: String, position: u32, offset_from: usize, offset_to: usize) -> Self {
Self {
text,
position,
offset_from,
offset_to,
variant: false,
}
}
}
pub trait Tokenizer: Send + Sync + Clone + 'static {
fn tokenize(&self, text: &str) -> Vec<Token>;
fn tokenize_with(&self, text: &str, hint: Option<&str>, purpose: Purpose) -> Vec<Token> {
let _ = (hint, purpose);
self.tokenize(text)
}
}
#[derive(Debug, Clone, Default)]
pub struct SimpleTokenizer;
impl Tokenizer for SimpleTokenizer {
fn tokenize(&self, text: &str) -> Vec<Token> {
tokenize_and_clean(text, std::convert::identity)
}
}
#[derive(Debug, Clone, Default)]
pub struct RawTokenizer;
impl Tokenizer for RawTokenizer {
fn tokenize(&self, text: &str) -> Vec<Token> {
let trimmed = text.trim();
if trimmed.is_empty() {
return Vec::new();
}
let offset = text.as_ptr() as usize;
let trimmed_offset = trimmed.as_ptr() as usize - offset;
vec![Token::new(
trimmed.to_string(),
0,
trimmed_offset,
trimmed_offset + trimmed.len(),
)]
}
}
#[derive(Debug, Clone, Default)]
pub struct RawCiTokenizer;
impl Tokenizer for RawCiTokenizer {
fn tokenize(&self, text: &str) -> Vec<Token> {
let trimmed = text.trim();
if trimmed.is_empty() {
return Vec::new();
}
let offset = text.as_ptr() as usize;
let trimmed_offset = trimmed.as_ptr() as usize - offset;
vec![Token::new(
lowercase_word(trimmed),
0,
trimmed_offset,
trimmed_offset + trimmed.len(),
)]
}
}
#[inline]
fn lowercase_word(word: &str) -> String {
if word.is_ascii() {
if word.bytes().all(|b| !b.is_ascii_uppercase()) {
return word.to_string();
}
let mut s = word.to_string();
s.make_ascii_lowercase();
s
} else {
word.chars().flat_map(|c| c.to_lowercase()).collect()
}
}
#[inline]
pub(super) fn clean_word(word: &str) -> String {
if word.is_ascii() {
let bytes = word.as_bytes();
if bytes
.iter()
.all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit())
{
return word.to_string();
}
let mut result = String::with_capacity(bytes.len());
for &b in bytes {
if b.is_ascii_alphanumeric() {
result.push(b.to_ascii_lowercase() as char);
}
}
result
} else {
word.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(|c| c.to_lowercase())
.collect()
}
}
fn tokenize_and_clean(text: &str, transform: impl Fn(String) -> String) -> Vec<Token> {
tokenize_and_clean_filtered(text, |word| Some(transform(word)))
}
fn tokenize_and_clean_filtered(
text: &str,
transform: impl Fn(String) -> Option<String>,
) -> Vec<Token> {
let mut tokens = Vec::with_capacity(text.len() / 5);
let mut position = 0u32;
for (offset, word) in split_whitespace_with_offsets(text) {
if !word.is_empty() {
let cleaned = clean_word(word);
if !cleaned.is_empty() {
if let Some(text) = transform(cleaned) {
tokens.push(Token::new(text, position, offset, offset + word.len()));
}
position += 1;
}
}
}
tokens
}
pub(super) fn split_whitespace_with_offsets(text: &str) -> impl Iterator<Item = (usize, &str)> {
let base = text.as_ptr() as usize;
text.split_whitespace()
.map(move |word| (word.as_ptr() as usize - base, word))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[allow(missing_docs)]
#[derive(Default)]
pub enum Language {
Arabic,
Danish,
Dutch,
#[default]
English,
Finnish,
French,
German,
Greek,
Hungarian,
Italian,
Norwegian,
Portuguese,
Romanian,
Russian,
Spanish,
Swedish,
Tamil,
Turkish,
}
impl Language {
fn to_algorithm(self) -> Algorithm {
match self {
Language::Arabic => Algorithm::Arabic,
Language::Danish => Algorithm::Danish,
Language::Dutch => Algorithm::Dutch,
Language::English => Algorithm::English,
Language::Finnish => Algorithm::Finnish,
Language::French => Algorithm::French,
Language::German => Algorithm::German,
Language::Greek => Algorithm::Greek,
Language::Hungarian => Algorithm::Hungarian,
Language::Italian => Algorithm::Italian,
Language::Norwegian => Algorithm::Norwegian,
Language::Portuguese => Algorithm::Portuguese,
Language::Romanian => Algorithm::Romanian,
Language::Russian => Algorithm::Russian,
Language::Spanish => Algorithm::Spanish,
Language::Swedish => Algorithm::Swedish,
Language::Tamil => Algorithm::Tamil,
Language::Turkish => Algorithm::Turkish,
}
}
pub(super) fn to_stop_words_language(self) -> LANGUAGE {
match self {
Language::Arabic => LANGUAGE::Arabic,
Language::Danish => LANGUAGE::Danish,
Language::Dutch => LANGUAGE::Dutch,
Language::English => LANGUAGE::English,
Language::Finnish => LANGUAGE::Finnish,
Language::French => LANGUAGE::French,
Language::German => LANGUAGE::German,
Language::Greek => LANGUAGE::Greek,
Language::Hungarian => LANGUAGE::Hungarian,
Language::Italian => LANGUAGE::Italian,
Language::Norwegian => LANGUAGE::Norwegian,
Language::Portuguese => LANGUAGE::Portuguese,
Language::Romanian => LANGUAGE::Romanian,
Language::Russian => LANGUAGE::Russian,
Language::Spanish => LANGUAGE::Spanish,
Language::Swedish => LANGUAGE::Swedish,
Language::Tamil => LANGUAGE::Tamil,
Language::Turkish => LANGUAGE::Turkish,
}
}
}
#[derive(Debug, Clone)]
pub struct StopWordTokenizer<T: Tokenizer> {
inner: T,
stop_words: HashSet<String>,
}
use std::collections::HashSet;
impl<T: Tokenizer> StopWordTokenizer<T> {
pub fn new(inner: T, language: Language) -> Self {
let stop_words: HashSet<String> = stop_words::get(language.to_stop_words_language())
.iter()
.map(|s| s.to_string())
.collect();
Self { inner, stop_words }
}
pub fn english(inner: T) -> Self {
Self::new(inner, Language::English)
}
pub fn with_custom_stop_words(inner: T, stop_words: HashSet<String>) -> Self {
Self { inner, stop_words }
}
pub fn is_stop_word(&self, word: &str) -> bool {
self.stop_words.contains(word)
}
}
impl<T: Tokenizer> Tokenizer for StopWordTokenizer<T> {
fn tokenize(&self, text: &str) -> Vec<Token> {
self.inner
.tokenize(text)
.into_iter()
.filter(|token| !self.stop_words.contains(token.text.as_str()))
.collect()
}
}
#[derive(Debug, Clone)]
pub struct StemmerTokenizer {
language: Language,
}
impl StemmerTokenizer {
pub fn new(language: Language) -> Self {
Self { language }
}
pub fn english() -> Self {
Self::new(Language::English)
}
}
impl Default for StemmerTokenizer {
fn default() -> Self {
Self::english()
}
}
impl Tokenizer for StemmerTokenizer {
fn tokenize(&self, text: &str) -> Vec<Token> {
let stemmer = rust_stemmers::Stemmer::create(self.language.to_algorithm());
tokenize_and_clean(text, |s| stemmer.stem(&s).into_owned())
}
}
#[derive(Debug, Clone)]
pub struct MultiLanguageStemmer {
default_language: Language,
}
impl MultiLanguageStemmer {
pub fn new(default_language: Language) -> Self {
Self { default_language }
}
pub fn tokenize_with_language(&self, text: &str, language: Language) -> Vec<Token> {
let stemmer = rust_stemmers::Stemmer::create(language.to_algorithm());
tokenize_and_clean(text, |s| stemmer.stem(&s).into_owned())
}
pub fn default_language(&self) -> Language {
self.default_language
}
}
impl Default for MultiLanguageStemmer {
fn default() -> Self {
Self::new(Language::English)
}
}
impl Tokenizer for MultiLanguageStemmer {
fn tokenize(&self, text: &str) -> Vec<Token> {
self.tokenize_with_language(text, self.default_language)
}
}
#[derive(Clone)]
pub struct LanguageAwareTokenizer<F>
where
F: Fn(&str) -> Language + Clone + Send + Sync + 'static,
{
language_selector: F,
stemmer: MultiLanguageStemmer,
}
impl<F> LanguageAwareTokenizer<F>
where
F: Fn(&str) -> Language + Clone + Send + Sync + 'static,
{
pub fn new(language_selector: F) -> Self {
Self {
language_selector,
stemmer: MultiLanguageStemmer::default(),
}
}
pub fn tokenize_with_hint(&self, text: &str, language_hint: &str) -> Vec<Token> {
let language = (self.language_selector)(language_hint);
self.stemmer.tokenize_with_language(text, language)
}
}
impl<F> Tokenizer for LanguageAwareTokenizer<F>
where
F: Fn(&str) -> Language + Clone + Send + Sync + 'static,
{
fn tokenize(&self, text: &str) -> Vec<Token> {
self.stemmer.tokenize_with_language(text, Language::English)
}
fn tokenize_with(&self, text: &str, hint: Option<&str>, _purpose: Purpose) -> Vec<Token> {
match hint {
Some(hint) => self.tokenize_with_hint(text, hint),
None => Tokenizer::tokenize(self, text),
}
}
}
pub fn parse_language(s: &str) -> Language {
match s.to_lowercase().as_str() {
"ar" | "arabic" => Language::Arabic,
"da" | "danish" => Language::Danish,
"nl" | "dutch" => Language::Dutch,
"en" | "english" => Language::English,
"fi" | "finnish" => Language::Finnish,
"fr" | "french" => Language::French,
"de" | "german" => Language::German,
"el" | "greek" => Language::Greek,
"hu" | "hungarian" => Language::Hungarian,
"it" | "italian" => Language::Italian,
"no" | "norwegian" => Language::Norwegian,
"pt" | "portuguese" => Language::Portuguese,
"ro" | "romanian" => Language::Romanian,
"ru" | "russian" => Language::Russian,
"es" | "spanish" => Language::Spanish,
"sv" | "swedish" => Language::Swedish,
"ta" | "tamil" => Language::Tamil,
"tr" | "turkish" => Language::Turkish,
_ => Language::English, }
}
pub fn parse_language_opt(s: &str) -> Option<Language> {
Some(match s.trim().to_lowercase().as_str() {
"ar" | "arabic" => Language::Arabic,
"da" | "danish" => Language::Danish,
"nl" | "dutch" => Language::Dutch,
"en" | "english" => Language::English,
"fi" | "finnish" => Language::Finnish,
"fr" | "french" => Language::French,
"de" | "german" => Language::German,
"el" | "greek" => Language::Greek,
"hu" | "hungarian" => Language::Hungarian,
"it" | "italian" => Language::Italian,
"no" | "norwegian" => Language::Norwegian,
"pt" | "portuguese" => Language::Portuguese,
"ro" | "romanian" => Language::Romanian,
"ru" | "russian" => Language::Russian,
"es" | "spanish" => Language::Spanish,
"sv" | "swedish" => Language::Swedish,
"ta" | "tamil" => Language::Tamil,
"tr" | "turkish" => Language::Turkish,
_ => return None,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Script {
Latin,
Cyrillic,
Greek,
Arabic,
Tamil,
Other,
}
impl Script {
pub fn of_token(token: &str) -> Script {
let Some(c) = token.chars().find(|c| c.is_alphabetic()) else {
return Script::Other;
};
Script::of_char(c)
}
fn of_char(c: char) -> Script {
match c as u32 {
0x0041..=0x024F | 0x1E00..=0x1EFF => Script::Latin,
0x0370..=0x03FF | 0x1F00..=0x1FFF => Script::Greek,
0x0400..=0x052F => Script::Cyrillic,
0x0600..=0x06FF | 0x0750..=0x077F | 0x08A0..=0x08FF => Script::Arabic,
0x0B80..=0x0BFF => Script::Tamil,
_ => Script::Other,
}
}
}
impl Language {
pub fn script(self) -> Script {
match self {
Language::Russian => Script::Cyrillic,
Language::Greek => Script::Greek,
Language::Arabic => Script::Arabic,
Language::Tamil => Script::Tamil,
_ => Script::Latin,
}
}
}
thread_local! {
static STEMMER_CACHE: std::cell::RefCell<HashMap<Language, rust_stemmers::Stemmer>> =
std::cell::RefCell::new(HashMap::new());
}
pub(super) fn with_stemmers<R>(
languages: &[Language],
f: impl FnOnce(&[&rust_stemmers::Stemmer]) -> R,
) -> R {
STEMMER_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
for language in languages {
cache
.entry(*language)
.or_insert_with(|| rust_stemmers::Stemmer::create(language.to_algorithm()));
}
let stemmers: Vec<&rust_stemmers::Stemmer> =
languages.iter().map(|language| &cache[language]).collect();
f(&stemmers)
})
}
pub fn language_code(language: Language) -> &'static str {
match language {
Language::Arabic => "ar",
Language::Danish => "da",
Language::Dutch => "nl",
Language::English => "en",
Language::Finnish => "fi",
Language::French => "fr",
Language::German => "de",
Language::Greek => "el",
Language::Hungarian => "hu",
Language::Italian => "it",
Language::Norwegian => "no",
Language::Portuguese => "pt",
Language::Romanian => "ro",
Language::Russian => "ru",
Language::Spanish => "es",
Language::Swedish => "sv",
Language::Tamil => "ta",
Language::Turkish => "tr",
}
}
pub type BoxedTokenizer = Box<dyn TokenizerClone>;
pub trait TokenizerClone: Send + Sync {
fn tokenize(&self, text: &str) -> Vec<Token>;
fn tokenize_with(&self, text: &str, hint: Option<&str>, purpose: Purpose) -> Vec<Token>;
fn clone_box(&self) -> BoxedTokenizer;
}
impl<T: Tokenizer> TokenizerClone for T {
fn tokenize(&self, text: &str) -> Vec<Token> {
Tokenizer::tokenize(self, text)
}
fn tokenize_with(&self, text: &str, hint: Option<&str>, purpose: Purpose) -> Vec<Token> {
Tokenizer::tokenize_with(self, text, hint, purpose)
}
fn clone_box(&self) -> BoxedTokenizer {
Box::new(self.clone())
}
}
impl Clone for BoxedTokenizer {
fn clone(&self) -> Self {
self.clone_box()
}
}
#[derive(Clone)]
pub struct TokenizerRegistry {
tokenizers: Arc<RwLock<HashMap<String, BoxedTokenizer>>>,
dynamic: Arc<RwLock<HashMap<String, BoxedTokenizer>>>,
}
impl TokenizerRegistry {
pub fn new() -> Self {
let registry = Self {
tokenizers: Arc::new(RwLock::new(HashMap::new())),
dynamic: Arc::new(RwLock::new(HashMap::new())),
};
registry.register_defaults();
registry
}
fn register_defaults(&self) {
self.register("simple", SimpleTokenizer);
self.register("default", SimpleTokenizer);
self.register("raw", RawTokenizer);
self.register("raw_ci", RawCiTokenizer);
self.register("en_stem", StemmerTokenizer::new(Language::English));
self.register("english", StemmerTokenizer::new(Language::English));
self.register("ar_stem", StemmerTokenizer::new(Language::Arabic));
self.register("arabic", StemmerTokenizer::new(Language::Arabic));
self.register("da_stem", StemmerTokenizer::new(Language::Danish));
self.register("danish", StemmerTokenizer::new(Language::Danish));
self.register("nl_stem", StemmerTokenizer::new(Language::Dutch));
self.register("dutch", StemmerTokenizer::new(Language::Dutch));
self.register("fi_stem", StemmerTokenizer::new(Language::Finnish));
self.register("finnish", StemmerTokenizer::new(Language::Finnish));
self.register("fr_stem", StemmerTokenizer::new(Language::French));
self.register("french", StemmerTokenizer::new(Language::French));
self.register("de_stem", StemmerTokenizer::new(Language::German));
self.register("german", StemmerTokenizer::new(Language::German));
self.register("el_stem", StemmerTokenizer::new(Language::Greek));
self.register("greek", StemmerTokenizer::new(Language::Greek));
self.register("hu_stem", StemmerTokenizer::new(Language::Hungarian));
self.register("hungarian", StemmerTokenizer::new(Language::Hungarian));
self.register("it_stem", StemmerTokenizer::new(Language::Italian));
self.register("italian", StemmerTokenizer::new(Language::Italian));
self.register("no_stem", StemmerTokenizer::new(Language::Norwegian));
self.register("norwegian", StemmerTokenizer::new(Language::Norwegian));
self.register("pt_stem", StemmerTokenizer::new(Language::Portuguese));
self.register("portuguese", StemmerTokenizer::new(Language::Portuguese));
self.register("ro_stem", StemmerTokenizer::new(Language::Romanian));
self.register("romanian", StemmerTokenizer::new(Language::Romanian));
self.register("ru_stem", StemmerTokenizer::new(Language::Russian));
self.register("russian", StemmerTokenizer::new(Language::Russian));
self.register("es_stem", StemmerTokenizer::new(Language::Spanish));
self.register("spanish", StemmerTokenizer::new(Language::Spanish));
self.register("sv_stem", StemmerTokenizer::new(Language::Swedish));
self.register("swedish", StemmerTokenizer::new(Language::Swedish));
self.register("ta_stem", StemmerTokenizer::new(Language::Tamil));
self.register("tamil", StemmerTokenizer::new(Language::Tamil));
self.register("tr_stem", StemmerTokenizer::new(Language::Turkish));
self.register("turkish", StemmerTokenizer::new(Language::Turkish));
self.register(
"en_stop",
StopWordTokenizer::new(SimpleTokenizer, Language::English),
);
self.register(
"de_stop",
StopWordTokenizer::new(SimpleTokenizer, Language::German),
);
self.register(
"fr_stop",
StopWordTokenizer::new(SimpleTokenizer, Language::French),
);
self.register(
"ru_stop",
StopWordTokenizer::new(SimpleTokenizer, Language::Russian),
);
self.register(
"es_stop",
StopWordTokenizer::new(SimpleTokenizer, Language::Spanish),
);
self.register(
"en_stem_stop",
StopWordTokenizer::new(StemmerTokenizer::new(Language::English), Language::English),
);
self.register(
"de_stem_stop",
StopWordTokenizer::new(StemmerTokenizer::new(Language::German), Language::German),
);
self.register(
"fr_stem_stop",
StopWordTokenizer::new(StemmerTokenizer::new(Language::French), Language::French),
);
self.register(
"ru_stem_stop",
StopWordTokenizer::new(StemmerTokenizer::new(Language::Russian), Language::Russian),
);
self.register(
"es_stem_stop",
StopWordTokenizer::new(StemmerTokenizer::new(Language::Spanish), Language::Spanish),
);
}
pub fn register<T: Tokenizer>(&self, name: &str, tokenizer: T) {
let mut tokenizers = self.tokenizers.write();
tokenizers.insert(name.to_string(), Box::new(tokenizer));
}
pub fn get(&self, name: &str) -> Option<BoxedTokenizer> {
if name.starts_with("lex(") {
if let Some(tokenizer) = self.dynamic.read().get(name) {
return Some(tokenizer.clone());
}
let tokenizer = TokenizerSpec::parse(name)
.ok()
.and_then(|spec| spec.dynamic_tokenizer())?;
self.dynamic
.write()
.entry(name.to_string())
.or_insert_with(|| tokenizer.clone());
return Some(tokenizer);
}
let tokenizers = self.tokenizers.read();
tokenizers.get(name).cloned()
}
pub fn contains(&self, name: &str) -> bool {
let tokenizers = self.tokenizers.read();
tokenizers.contains_key(name)
}
pub fn names(&self) -> Vec<String> {
let tokenizers = self.tokenizers.read();
tokenizers.keys().cloned().collect()
}
}
impl Default for TokenizerRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simple_tokenizer() {
let tokenizer = SimpleTokenizer;
let tokens = Tokenizer::tokenize(&tokenizer, "Hello World");
assert_eq!(tokens.len(), 2);
assert_eq!(tokens[0].text, "hello");
assert_eq!(tokens[0].position, 0);
assert_eq!(tokens[1].text, "world");
assert_eq!(tokens[1].position, 1);
}
#[test]
fn test_raw_tokenizer() {
let tokenizer = RawTokenizer;
let tokens = Tokenizer::tokenize(&tokenizer, "Hello, World!");
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0].text, "Hello, World!");
assert_eq!(tokens[0].position, 0);
}
#[test]
fn test_raw_tokenizer_trims() {
let tokenizer = RawTokenizer;
let tokens = Tokenizer::tokenize(&tokenizer, " spaced ");
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0].text, "spaced");
assert_eq!(tokens[0].offset_from, 2);
}
#[test]
fn test_raw_tokenizer_empty() {
let tokenizer = RawTokenizer;
assert!(Tokenizer::tokenize(&tokenizer, "").is_empty());
assert!(Tokenizer::tokenize(&tokenizer, " ").is_empty());
}
#[test]
fn test_raw_ci_tokenizer() {
let tokenizer = RawCiTokenizer;
let tokens = Tokenizer::tokenize(&tokenizer, "Hello, World!");
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0].text, "hello, world!");
assert_eq!(tokens[0].position, 0);
}
#[test]
fn test_raw_ci_tokenizer_preserves_structure() {
let tokenizer = RawCiTokenizer;
let tokens = Tokenizer::tokenize(&tokenizer, "HTTPS://Example.COM/Page");
assert_eq!(tokens.len(), 1);
assert_eq!(tokens[0].text, "https://example.com/page");
}
#[test]
fn test_simple_tokenizer_strips_punctuation() {
let tokenizer = SimpleTokenizer;
let tokens = Tokenizer::tokenize(&tokenizer, "Hello, World!");
assert_eq!(tokens.len(), 2);
assert_eq!(tokens[0].text, "hello");
assert_eq!(tokens[1].text, "world");
}
#[test]
fn test_empty_text() {
let tokenizer = SimpleTokenizer;
let tokens = Tokenizer::tokenize(&tokenizer, "");
assert!(tokens.is_empty());
}
#[test]
fn test_stemmer_tokenizer_english() {
let tokenizer = StemmerTokenizer::english();
let tokens = Tokenizer::tokenize(&tokenizer, "Dogs are running quickly");
assert_eq!(tokens.len(), 4);
assert_eq!(tokens[0].text, "dog"); assert_eq!(tokens[1].text, "are"); assert_eq!(tokens[2].text, "run"); assert_eq!(tokens[3].text, "quick"); }
#[test]
fn test_stemmer_tokenizer_preserves_offsets() {
let tokenizer = StemmerTokenizer::english();
let tokens = Tokenizer::tokenize(&tokenizer, "Running dogs");
assert_eq!(tokens.len(), 2);
assert_eq!(tokens[0].text, "run");
assert_eq!(tokens[0].offset_from, 0);
assert_eq!(tokens[0].offset_to, 7); assert_eq!(tokens[1].text, "dog");
assert_eq!(tokens[1].offset_from, 8);
assert_eq!(tokens[1].offset_to, 12); }
#[test]
fn test_stemmer_tokenizer_german() {
let tokenizer = StemmerTokenizer::new(Language::German);
let tokens = Tokenizer::tokenize(&tokenizer, "Häuser Bücher");
assert_eq!(tokens.len(), 2);
assert_eq!(tokens[0].text, "haus"); assert_eq!(tokens[1].text, "buch"); }
#[test]
fn test_stemmer_tokenizer_russian() {
let tokenizer = StemmerTokenizer::new(Language::Russian);
let tokens = Tokenizer::tokenize(&tokenizer, "бегущие собаки");
assert_eq!(tokens.len(), 2);
assert_eq!(tokens[0].text, "бегущ"); assert_eq!(tokens[1].text, "собак"); }
#[test]
fn test_multi_language_stemmer() {
let stemmer = MultiLanguageStemmer::new(Language::English);
let tokens = stemmer.tokenize_with_language("running dogs", Language::English);
assert_eq!(tokens[0].text, "run");
assert_eq!(tokens[1].text, "dog");
let tokens = stemmer.tokenize_with_language("Häuser Bücher", Language::German);
assert_eq!(tokens[0].text, "haus");
assert_eq!(tokens[1].text, "buch");
let tokens = stemmer.tokenize_with_language("бегущие собаки", Language::Russian);
assert_eq!(tokens[0].text, "бегущ");
assert_eq!(tokens[1].text, "собак");
}
#[test]
fn test_language_aware_tokenizer() {
let tokenizer = LanguageAwareTokenizer::new(parse_language);
let tokens = tokenizer.tokenize_with_hint("running dogs", "en");
assert_eq!(tokens[0].text, "run");
assert_eq!(tokens[1].text, "dog");
let tokens = tokenizer.tokenize_with_hint("Häuser Bücher", "de");
assert_eq!(tokens[0].text, "haus");
assert_eq!(tokens[1].text, "buch");
let tokens = tokenizer.tokenize_with_hint("бегущие собаки", "russian");
assert_eq!(tokens[0].text, "бегущ");
assert_eq!(tokens[1].text, "собак");
}
#[test]
fn test_parse_language() {
assert_eq!(parse_language("en"), Language::English);
assert_eq!(parse_language("english"), Language::English);
assert_eq!(parse_language("English"), Language::English);
assert_eq!(parse_language("de"), Language::German);
assert_eq!(parse_language("german"), Language::German);
assert_eq!(parse_language("ru"), Language::Russian);
assert_eq!(parse_language("russian"), Language::Russian);
assert_eq!(parse_language("unknown"), Language::English); }
#[test]
fn test_tokenizer_registry_defaults() {
let registry = TokenizerRegistry::new();
assert!(registry.contains("simple"));
assert!(registry.contains("raw"));
assert!(registry.contains("raw_ci"));
assert!(registry.contains("raw"));
assert!(registry.contains("raw_ci"));
assert!(registry.contains("en_stem"));
assert!(registry.contains("german"));
assert!(registry.contains("russian"));
}
#[test]
fn test_tokenizer_registry_get() {
let registry = TokenizerRegistry::new();
let tokenizer = registry.get("en_stem").unwrap();
let tokens = tokenizer.tokenize("running dogs");
assert_eq!(tokens[0].text, "run");
assert_eq!(tokens[1].text, "dog");
let tokenizer = registry.get("german").unwrap();
let tokens = tokenizer.tokenize("Häuser Bücher");
assert_eq!(tokens[0].text, "haus");
assert_eq!(tokens[1].text, "buch");
}
#[test]
fn test_tokenizer_registry_custom() {
let registry = TokenizerRegistry::new();
registry.register("my_tokenizer", SimpleTokenizer);
assert!(registry.contains("my_tokenizer"));
let tokenizer = registry.get("my_tokenizer").unwrap();
let tokens = tokenizer.tokenize("Hello World");
assert_eq!(tokens[0].text, "hello");
assert_eq!(tokens[1].text, "world");
}
#[test]
fn test_tokenizer_registry_nonexistent() {
let registry = TokenizerRegistry::new();
assert!(registry.get("nonexistent").is_none());
}
#[test]
fn test_stop_word_tokenizer_english() {
let tokenizer = StopWordTokenizer::english(SimpleTokenizer);
let tokens = Tokenizer::tokenize(&tokenizer, "The quick brown fox jumps over the lazy dog");
let texts: Vec<&str> = tokens.iter().map(|t| t.text.as_str()).collect();
assert!(!texts.contains(&"the"));
assert!(!texts.contains(&"over"));
assert!(texts.contains(&"quick"));
assert!(texts.contains(&"brown"));
assert!(texts.contains(&"fox"));
assert!(texts.contains(&"jumps"));
assert!(texts.contains(&"lazy"));
assert!(texts.contains(&"dog"));
}
#[test]
fn test_stop_word_tokenizer_with_stemmer() {
let tokenizer = StopWordTokenizer::new(StemmerTokenizer::english(), Language::English);
let tokens = Tokenizer::tokenize(&tokenizer, "elephants galaxies quantum");
let texts: Vec<&str> = tokens.iter().map(|t| t.text.as_str()).collect();
assert!(texts.contains(&"eleph")); assert!(texts.contains(&"galaxi")); assert!(texts.contains(&"quantum")); }
#[test]
fn test_stop_word_tokenizer_german() {
let tokenizer = StopWordTokenizer::new(SimpleTokenizer, Language::German);
let tokens = Tokenizer::tokenize(&tokenizer, "Der Hund und die Katze");
let texts: Vec<&str> = tokens.iter().map(|t| t.text.as_str()).collect();
assert!(!texts.contains(&"der"));
assert!(!texts.contains(&"und"));
assert!(!texts.contains(&"die"));
assert!(texts.contains(&"hund"));
assert!(texts.contains(&"katze"));
}
#[test]
fn test_stop_word_tokenizer_custom() {
let custom_stops: HashSet<String> = ["foo", "bar"].iter().map(|s| s.to_string()).collect();
let tokenizer = StopWordTokenizer::with_custom_stop_words(SimpleTokenizer, custom_stops);
let tokens = Tokenizer::tokenize(&tokenizer, "foo baz bar qux");
let texts: Vec<&str> = tokens.iter().map(|t| t.text.as_str()).collect();
assert!(!texts.contains(&"foo"));
assert!(!texts.contains(&"bar"));
assert!(texts.contains(&"baz"));
assert!(texts.contains(&"qux"));
}
#[test]
fn test_stop_word_tokenizer_is_stop_word() {
let tokenizer = StopWordTokenizer::english(SimpleTokenizer);
assert!(tokenizer.is_stop_word("the"));
assert!(tokenizer.is_stop_word("and"));
assert!(tokenizer.is_stop_word("is"));
assert!(!tokenizer.is_stop_word("elephant"));
assert!(!tokenizer.is_stop_word("quantum"));
}
#[test]
fn test_tokenizer_registry_stop_word_tokenizers() {
let registry = TokenizerRegistry::new();
assert!(registry.contains("en_stop"));
assert!(registry.contains("en_stem_stop"));
assert!(registry.contains("de_stop"));
assert!(registry.contains("ru_stop"));
let tokenizer = registry.get("en_stop").unwrap();
let tokens = tokenizer.tokenize("The quick fox");
let texts: Vec<&str> = tokens.iter().map(|t| t.text.as_str()).collect();
assert!(!texts.contains(&"the"));
assert!(texts.contains(&"quick"));
assert!(texts.contains(&"fox"));
let tokenizer = registry.get("en_stem_stop").unwrap();
let tokens = tokenizer.tokenize("elephants galaxies");
let texts: Vec<&str> = tokens.iter().map(|t| t.text.as_str()).collect();
assert!(texts.contains(&"eleph")); assert!(texts.contains(&"galaxi")); }
fn hinted<T: Tokenizer>(tokenizer: &T, text: &str, hint: Option<&str>) -> Vec<Token> {
Tokenizer::tokenize_with(tokenizer, text, hint, Purpose::Index)
}
fn lex(spec: &str) -> LexTokenizer {
LexTokenizer::new(LexOptions::parse(spec).unwrap())
}
fn texts(tokens: &[Token]) -> Vec<&str> {
tokens.iter().map(|t| t.text.as_str()).collect()
}
fn positions(tokens: &[Token]) -> Vec<u32> {
tokens.iter().map(|t| t.position).collect()
}
#[test]
fn unicode_segmenter_splits_on_word_boundaries_and_folds() {
let plain = lex("by: languages, segmenter: unicode, stem: snowball, variants: false");
let tokens = hinted(
&plain,
"Float-zero determinants: p53/CO2, 10.1007/s1 résumé",
None,
);
assert_eq!(
texts(&tokens),
vec![
"float",
"zero",
"determinants",
"p53",
"co2",
"10.1007",
"s1",
"resume"
]
);
assert_eq!(positions(&tokens), (0..8).collect::<Vec<u32>>());
assert_eq!(
&"Float-zero determinants: p53/CO2, 10.1007/s1 résumé"
[tokens[7].offset_from..tokens[7].offset_to],
"résumé"
);
let english = lex("by: languages, segmenter: unicode, stem: snowball, variants: false");
assert_eq!(
texts(&hinted(&english, "Running foxes' café", Some("en"))),
vec!["run", "fox", "cafe"]
);
assert_eq!(
texts(&hinted(
&lex("by: languages, segmenter: simple, stem: snowball, variants: false"),
"Float-zero café",
Some("en")
)),
vec!["float", "zero", "cafe"]
);
assert_eq!(texts(&hinted(&plain, "ёлка", None)), vec!["елка"]);
let stopping = lex(
"by: languages, stop_words: true, segmenter: unicode, stem: snowball, variants: false",
);
let tokens = hinted(&stopping, "state-of-the-art résumé", Some("en"));
assert_eq!(tokens.len(), 3, "{:?}", texts(&tokens));
assert_eq!(&texts(&tokens)[..2], ["state", "art"]);
assert!(tokens[2].text.starts_with("resum"), "{:?}", tokens[2].text);
assert!(tokens[2].text.is_ascii(), "folded after stemming");
assert_eq!(positions(&tokens), vec![0, 3, 4]);
}
#[test]
fn unicode_segmenter_bigrams_cjk_runs() {
let t = lex("by: languages, segmenter: unicode, stem: snowball, variants: false");
let tokens = hinted(&t, "東京都 tower", Some("en"));
assert_eq!(texts(&tokens), vec!["東京", "京都", "tower"]);
assert_eq!(positions(&tokens), vec![0, 1, 2]);
assert_eq!(
&"東京都 tower"[tokens[1].offset_from..tokens[1].offset_to],
"京都"
);
assert_eq!(
texts(&hinted(&t, "東 tower 京都", None)),
vec!["東", "tower", "京都"]
);
assert_eq!(
texts(&hinted(&t, "トウキョウ タワー", None)),
vec!["トウ", "ウキ", "キョ", "ョウ", "タワ", "ワー"]
);
assert_eq!(texts(&hinted(&t, "東京tower", None)), vec!["東京", "tower"]);
let query = hinted(&t, "東京都", None);
assert_eq!(positions(&query), vec![0, 1]);
}
#[test]
fn dynamic_stemmer_drops_stop_words_but_keeps_positions() {
let stemmer = lex(
"by: languages, stop_words: true, segmenter: simple, stem: snowball, variants: false",
);
let tokens = hinted(&stemmer, "Quantum of the Art", Some("en"));
assert_eq!(texts(&tokens), vec!["quantum", "art"]);
assert_eq!(positions(&tokens), vec![0, 3]);
assert_eq!(tokens[1].offset_from, "Quantum of the ".len());
let tokens = hinted(&stemmer, "бегущие и собаки the foxes", Some("ru,en"));
assert_eq!(texts(&tokens), vec!["бегущ", "собак", "fox"]);
assert_eq!(positions(&tokens), vec![0, 2, 4]);
assert_eq!(
texts(&hinted(&stemmer, "the 日本語 fox", Some("en"))),
vec!["日本語", "fox"]
);
assert_eq!(
texts(&hinted(&stemmer, "the fox", None)),
vec!["the", "fox"]
);
let english = lex(
"by: languages, default: en, stop_words: true, segmenter: simple, stem: snowball, variants: false",
);
assert_eq!(texts(&hinted(&english, "the fox", None)), vec!["fox"]);
assert_eq!(
texts(&hinted(
&lex("by: languages, segmenter: simple, stem: snowball, variants: false"),
"the fox",
Some("en")
)),
vec!["the", "fox"]
);
assert!(hinted(&stemmer, "to be or not to be", Some("en")).is_empty());
}
#[test]
fn dynamic_stemmer_selects_language_from_hint() {
let stemmer = lex("by: languages, segmenter: simple, stem: snowball, variants: false");
assert_eq!(
texts(&hinted(&stemmer, "Running Foxes", Some("en"))),
vec!["run", "fox"]
);
assert_eq!(
texts(&hinted(&stemmer, "бегущие собаки", Some("ru"))),
vec!["бегущ", "собак"]
);
assert_eq!(
texts(&hinted(&stemmer, "Running Foxes", Some("xx"))),
vec!["running", "foxes"]
);
assert_eq!(
texts(&hinted(&stemmer, "Running Foxes", None)),
vec!["running", "foxes"]
);
assert_eq!(
texts(&Tokenizer::tokenize(&stemmer, "Running, Foxes!")),
vec!["running", "foxes"]
);
let english =
lex("by: languages, default: en, segmenter: simple, stem: snowball, variants: false");
assert_eq!(
texts(&hinted(&english, "Running Foxes", None)),
vec!["run", "fox"]
);
}
#[test]
fn dynamic_stemmer_routes_tokens_by_script() {
let stemmer = lex("by: languages, segmenter: simple, stem: snowball, variants: false");
assert_eq!(
texts(&hinted(&stemmer, "бегущие foxes", Some("ru,en"))),
vec!["бегущ", "fox"]
);
assert_eq!(
texts(&hinted(&stemmer, "бегущие foxes", Some("en, ru"))),
vec!["бегущ", "fox"]
);
assert_eq!(
texts(&hinted(&stemmer, "бегущие foxes", Some("ru"))),
vec!["бегущ", "foxes"]
);
assert_eq!(
texts(&hinted(&stemmer, "бегущие foxes", Some("en"))),
vec!["бегущие", "fox"]
);
assert_eq!(
texts(&hinted(&stemmer, "running", Some("de,en"))),
vec!["running"]
);
assert_eq!(
texts(&hinted(&stemmer, "running", Some("en,de"))),
vec!["run"]
);
let tokens = hinted(&stemmer, "бегущие foxes run", Some("ru,en"));
assert_eq!(
tokens.iter().map(|t| t.position).collect::<Vec<_>>(),
vec![0, 1, 2]
);
}
#[test]
fn script_detection_covers_supported_stemmer_scripts() {
assert_eq!(Script::of_token("hello"), Script::Latin);
assert_eq!(Script::of_token("straße"), Script::Latin);
assert_eq!(Script::of_token("собака"), Script::Cyrillic);
assert_eq!(Script::of_token("γεια"), Script::Greek);
assert_eq!(Script::of_token("مرحبا"), Script::Arabic);
assert_eq!(Script::of_token("தமிழ்"), Script::Tamil);
assert_eq!(Script::of_token("日本語"), Script::Other);
assert_eq!(Script::of_token("2024"), Script::Other);
assert_eq!(Language::Russian.script(), Script::Cyrillic);
assert_eq!(Language::Turkish.script(), Script::Latin);
}
#[test]
fn registry_builds_lex_tokenizers_from_specs() {
let registry = TokenizerRegistry::new();
let tokenizer = registry
.get("lex(by: languages, segmenter: simple, stem: snowball, variants: false)")
.expect("lex spec resolves without registration");
assert_eq!(
texts(&tokenizer.tokenize_with("Running Foxes", Some("en"), Purpose::Index)),
vec!["run", "fox"]
);
assert_eq!(
texts(&tokenizer.tokenize("Running Foxes")),
vec!["running", "foxes"]
);
assert!(
registry
.get("lex(by: languages, default: klingon)")
.is_none()
);
let stopping = registry
.get("lex(by: languages, stop_words: true, segmenter: simple, stem: snowball, variants: false)")
.expect("stop-word spec resolves");
let tokens = stopping.tokenize_with("the running foxes", Some("en"), Purpose::Index);
assert_eq!(texts(&tokens), vec!["run", "fox"]);
assert_eq!(positions(&tokens), vec![1, 2]);
let simple = registry.get("en_stem").unwrap();
assert_eq!(
texts(&simple.tokenize_with("Running Foxes", Some("ru"), Purpose::Exact)),
vec!["run", "fox"]
);
}
#[test]
fn spec_without_by_is_a_fixed_tokenizer_that_ignores_hints() {
let spec = TokenizerSpec::parse("lex(stop_words: true, stem: none)").unwrap();
assert_eq!(spec.hint_field(), None);
let tokenizer = spec.dynamic_tokenizer().unwrap();
let plain_tokens = tokenizer.tokenize("The running cells");
let plain = texts(&plain_tokens);
assert_eq!(plain, vec!["the", "running", "cells"]);
let hinted_tokens =
tokenizer.tokenize_with("The running cells", Some("en"), Purpose::Index);
assert_eq!(
texts(&hinted_tokens),
plain,
"hints are ignored without `by`"
);
let english = TokenizerSpec::parse(
"lex(default: en, stop_words: true, stem: snowball, variants: false)",
)
.unwrap();
let tokenizer = english.dynamic_tokenizer().unwrap();
assert_eq!(
texts(&tokenizer.tokenize_with("The running cells", Some("ru"), Purpose::Index)),
vec!["run", "cell"]
);
}
#[test]
fn parse_language_opt_rejects_unknown_codes() {
assert_eq!(parse_language_opt(" RU "), Some(Language::Russian));
assert_eq!(parse_language_opt("german"), Some(Language::German));
assert_eq!(parse_language_opt("xx"), None);
assert_eq!(parse_language_opt(""), None);
for language in [Language::English, Language::Russian, Language::Tamil] {
assert_eq!(parse_language_opt(language_code(language)), Some(language));
}
}
}