use alloc::string::String;
use unicode_normalization::UnicodeNormalization;
use unicode_normalization::char::{decompose_canonical, is_combining_mark};
use unicode_segmentation::UnicodeSegmentation;
pub const MAX_TOKEN_BYTES: usize = 64;
fn is_cjk_unigram(c: char) -> bool {
matches!(
c as u32,
0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x20000..=0x2FFFF
| 0x3041..=0x309F
)
}
#[derive(Debug, Default, Clone)]
pub struct Tokenizer {
norm: String,
token: String,
}
impl Tokenizer {
pub fn new() -> Self {
Self::default()
}
pub fn tokenize(&mut self, text: &str, sink: &mut dyn FnMut(&str)) {
self.norm.clear();
if text.is_ascii() {
self.norm.push_str(text);
} else {
self.norm.extend(text.nfkc());
}
let token = &mut self.token;
let mut prev_cjk: Option<char> = None;
let mut run_len = 0usize;
fn flush_cjk(
prev: &mut Option<char>,
run_len: &mut usize,
token: &mut String,
sink: &mut dyn FnMut(&str),
) {
if let Some(p) = prev.take()
&& *run_len == 1
{
token.clear();
token.push(p);
sink(token);
}
*run_len = 0;
}
for seg in self.norm.split_word_bounds() {
let mut chars = seg.chars();
let first = chars.next();
let single = first.is_some() && chars.next().is_none();
match first {
Some(c) if single && is_cjk_unigram(c) => {
if let Some(p) = prev_cjk {
token.clear();
token.push(p);
token.push(c);
sink(token);
}
prev_cjk = Some(c);
run_len += 1;
}
_ if seg.chars().any(char::is_alphanumeric) => {
flush_cjk(&mut prev_cjk, &mut run_len, token, sink);
token.clear();
for c in seg.chars() {
for lc in c.to_lowercase() {
fold_into(lc, token);
}
}
emit_truncated(token, sink);
}
_ => flush_cjk(&mut prev_cjk, &mut run_len, token, sink),
}
}
flush_cjk(&mut prev_cjk, &mut run_len, token, sink);
}
}
fn is_ignorable_format(c: char) -> bool {
matches!(
c as u32,
0xAD | 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0xFEFF
)
}
fn fold_into(c: char, out: &mut String) {
if c == 'ё' {
out.push('е');
return;
}
if is_ignorable_format(c) {
return;
}
if is_combining_mark(c) {
if !out.ends_with(|p: char| p.is_ascii_alphanumeric()) {
out.push(c);
}
return;
}
if !c.is_alphanumeric() {
if out.ends_with(char::is_alphanumeric) {
out.push(c);
}
return;
}
if c.is_ascii() {
out.push(c);
return;
}
let mut parts = [char::MAX; 8];
let mut n = 0usize;
decompose_canonical(c, |d| {
if n < parts.len() {
parts[n] = d;
}
n += 1;
});
if n <= parts.len() && n > 0 && parts[0].is_ascii_alphanumeric() {
for &d in &parts[..n] {
if !is_combining_mark(d) {
out.push(d);
}
}
} else {
out.push(c);
}
}
fn emit_truncated(token: &str, sink: &mut dyn FnMut(&str)) {
if token.is_empty() {
return;
}
let mut end = token.len().min(MAX_TOKEN_BYTES);
while !token.is_char_boundary(end) {
end -= 1;
}
sink(&token[..end]);
}