Skip to main content

kevy_text/
token.rs

1//! Script-aware, dictionary-free tokenization.
2//!
3//! - Latin/ASCII alphanumeric runs → lowercased word tokens (length
4//!   ≥ 2; single characters are noise).
5//! - CJK (unified ideographs, kana, hangul syllables) → adjacent
6//!   BIGRAMS; a lone CJK character emits a unigram so single-char
7//!   texts remain findable.
8//! - Tokens never cross a script boundary.
9
10/// Pluggable tokenizer ([`tokenize`] is the only shipped impl).
11/// # Examples
12///
13/// ```
14/// use kevy_text::{KevyTokenizer, Tokenizer};
15/// // The engine takes a Tokenizer, so a caller can swap the analysis
16/// // without touching the index.
17/// fn count<T: Tokenizer>(t: &T, s: &[u8]) -> usize { t.tokens(s).len() }
18/// assert_eq!(count(&KevyTokenizer, b"one two three"), 3);
19/// ```
20pub trait Tokenizer {
21    /// Produce tokens for `text` (UTF-8; invalid bytes are skipped).
22    fn tokens(&self, text: &[u8]) -> Vec<Vec<u8>>;
23}
24
25/// The default dictionary-free tokenizer.
26/// # Examples
27///
28/// ```
29/// use kevy_text::{KevyTokenizer, Tokenizer, tokenize};
30/// // The default analysis, and the same one the free function runs.
31/// assert_eq!(KevyTokenizer.tokens(b"Hello World"), tokenize(b"Hello World"));
32/// ```
33#[derive(Debug, Clone, Copy, Default)]
34pub struct KevyTokenizer;
35
36impl Tokenizer for KevyTokenizer {
37    fn tokens(&self, text: &[u8]) -> Vec<Vec<u8>> {
38        tokenize(text)
39    }
40}
41
42fn is_cjk(c: char) -> bool {
43    matches!(c,
44        '\u{4E00}'..='\u{9FFF}'   // CJK unified ideographs
45        | '\u{3400}'..='\u{4DBF}' // extension A
46        | '\u{3040}'..='\u{309F}' // hiragana
47        | '\u{30A0}'..='\u{30FF}' // katakana
48        | '\u{AC00}'..='\u{D7AF}' // hangul syllables
49    )
50}
51
52/// Tokenize per the default rules (see module doc).
53/// # Examples
54///
55/// ```
56/// use kevy_text::tokenize;
57/// // ASCII: folded to lower case, split on non-alphanumerics.
58/// assert_eq!(tokenize(b"Rust Storage-Engine"),
59///            vec![b"rust".to_vec(), b"storage".to_vec(), b"engine".to_vec()]);
60///
61/// // CJK without a dictionary: Han runs become overlapping bigrams, so a
62/// // two-character query matches the same way a word would — no word
63/// // list to ship, and nothing to be missing from it.
64/// let t = tokenize("全文検索".as_bytes());
65/// let got: Vec<String> = t.iter().map(|x| String::from_utf8_lossy(x).into_owned()).collect();
66/// assert_eq!(got, vec!["全文", "文検", "検索"]);
67///
68/// // A single Han character still indexes, so it is findable alone.
69/// assert_eq!(tokenize("犬".as_bytes()).len(), 1);
70/// ```
71pub fn tokenize(text: &[u8]) -> Vec<Vec<u8>> {
72    let s = String::from_utf8_lossy(text);
73    let mut out = Vec::new();
74    let mut word = String::new();
75    let mut prev_cjk: Option<char> = None;
76    let mut cjk_run = 0usize;
77
78    let flush_word = |word: &mut String, out: &mut Vec<Vec<u8>>| {
79        if word.chars().count() >= 2 {
80            out.push(word.to_lowercase().into_bytes());
81        }
82        word.clear();
83    };
84
85    for c in s.chars() {
86        if is_cjk(c) {
87            flush_word(&mut word, &mut out);
88            if let Some(p) = prev_cjk {
89                let mut bi = String::with_capacity(8);
90                bi.push(p);
91                bi.push(c);
92                out.push(bi.into_bytes());
93            }
94            prev_cjk = Some(c);
95            cjk_run += 1;
96        } else {
97            // end of a CJK run: a lone character still emits itself
98            if cjk_run == 1
99                && let Some(p) = prev_cjk
100            {
101                out.push(p.to_string().into_bytes());
102            }
103            prev_cjk = None;
104            cjk_run = 0;
105            if c.is_alphanumeric() {
106                word.push(c);
107            } else {
108                flush_word(&mut word, &mut out);
109            }
110        }
111    }
112    if cjk_run == 1
113        && let Some(p) = prev_cjk
114    {
115        out.push(p.to_string().into_bytes());
116    }
117    flush_word(&mut word, &mut out);
118    out
119}
120
121/// Tokens with the byte span each came from in the ORIGINAL `text`:
122/// `(token, start, end)` where `start..end` indexes `text` (a CJK bigram
123/// spans both its characters, a lowercased word its source range). This
124/// is what highlighting re-analyses a winning document's field with to
125/// point `<em>` spans at the source.
126///
127/// Requires valid UTF-8 — invalid input yields no spans rather than
128/// offsets that would be wrong once `from_utf8_lossy` shifted them. The
129/// tokens themselves match [`tokenize`] on valid input (a test pins it).
130/// # Examples
131///
132/// ```
133/// use kevy_text::tokenize_spans;
134/// // The same tokens, each with the BYTE range it came from — what
135/// // HIGHLIGHT needs to point back at the original text.
136/// let spans = tokenize_spans(b"rust storage");
137/// assert_eq!(spans[0].0, b"rust".to_vec());
138/// assert_eq!((spans[0].1, spans[0].2), (0, 4));
139/// assert_eq!((spans[1].1, spans[1].2), (5, 12));
140/// ```
141pub fn tokenize_spans(text: &[u8]) -> Vec<(Vec<u8>, usize, usize)> {
142    let Ok(s) = core::str::from_utf8(text) else {
143        return Vec::new();
144    };
145    let mut out = Vec::new();
146    let mut word = String::new();
147    let mut word_start = 0usize;
148    let mut prev_cjk: Option<(char, usize)> = None;
149    let mut cjk_run = 0usize;
150    let flush = |w: &mut String, ws: usize, we: usize, out: &mut Vec<(Vec<u8>, usize, usize)>| {
151        if w.chars().count() >= 2 {
152            out.push((w.to_lowercase().into_bytes(), ws, we));
153        }
154        w.clear();
155    };
156    for (idx, c) in s.char_indices() {
157        if is_cjk(c) {
158            flush(&mut word, word_start, idx, &mut out);
159            if let Some((p, ps)) = prev_cjk {
160                out.push((format!("{p}{c}").into_bytes(), ps, idx + c.len_utf8()));
161            }
162            prev_cjk = Some((c, idx));
163            cjk_run += 1;
164        } else {
165            if cjk_run == 1
166                && let Some((p, ps)) = prev_cjk
167            {
168                out.push((p.to_string().into_bytes(), ps, ps + p.len_utf8()));
169            }
170            prev_cjk = None;
171            cjk_run = 0;
172            if c.is_alphanumeric() {
173                if word.is_empty() {
174                    word_start = idx;
175                }
176                word.push(c);
177            } else {
178                flush(&mut word, word_start, idx, &mut out);
179            }
180        }
181    }
182    if cjk_run == 1
183        && let Some((p, ps)) = prev_cjk
184    {
185        out.push((p.to_string().into_bytes(), ps, ps + p.len_utf8()));
186    }
187    flush(&mut word, word_start, s.len(), &mut out);
188    out
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    fn toks(s: &str) -> Vec<String> {
196        tokenize(s.as_bytes()).into_iter().map(|t| String::from_utf8(t).unwrap()).collect()
197    }
198
199    #[test]
200    fn latin_words_lowercased_min_two() {
201        assert_eq!(toks("Hello, Rust world! a I"), vec!["hello", "rust", "world"]);
202        assert_eq!(toks("v2.7-alpha"), vec!["v2", "alpha"]);
203    }
204
205    #[test]
206    fn cjk_bigrams_no_dictionary() {
207        assert_eq!(toks("全文检索"), vec!["全文", "文检", "检索"]);
208        assert_eq!(toks("猫"), vec!["猫"], "lone CJK char = unigram");
209        assert_eq!(toks("ひらがな"), vec!["ひら", "らが", "がな"]);
210        assert_eq!(toks("한국어"), vec!["한국", "국어"]);
211    }
212
213    #[test]
214    fn mixed_scripts_never_cross() {
215        assert_eq!(
216            toks("Rust搜索engine引擎x"),
217            vec!["rust", "搜索", "engine", "引擎"],
218            "boundaries split; trailing single latin char dropped"
219        );
220        assert_eq!(toks("日本語abc漢字"), vec!["日本", "本語", "abc", "漢字"]);
221    }
222
223    #[test]
224    fn junk_and_empty() {
225        assert!(toks("").is_empty());
226        assert!(toks("!!! ...").is_empty());
227        let bad = tokenize(&[0xFF, 0xFE, b'o', b'k', b'a', b'y']);
228        assert_eq!(bad, vec![b"okay".to_vec()]);
229    }
230
231    // ---- tokenize_spans (highlighting) ------------------------------
232
233    /// Spans must slice the ORIGINAL text back to the source of each
234    /// token — a word to its (un-lowercased) source, a CJK bigram to both
235    /// characters.
236    #[test]
237    fn spans_point_at_the_source() {
238        let text = "a Quick brown 全文 fox";
239        let spans = tokenize_spans(text.as_bytes());
240        let got: Vec<(&str, &str)> = spans
241            .iter()
242            .map(|(t, s, e)| (std::str::from_utf8(t).unwrap(), &text[*s..*e]))
243            .collect();
244        assert_eq!(
245            got,
246            vec![
247                ("quick", "Quick"), // token lowercased, span is the source
248                ("brown", "brown"),
249                ("全文", "全文"), // a two-char run is one bigram, no unigram
250                ("fox", "fox"),
251            ],
252            "single-char 'a' dropped; every span slices its source"
253        );
254    }
255
256    /// On valid UTF-8 the tokens must match `tokenize` exactly — the span
257    /// variant is a superset, never a different tokenizer.
258    #[test]
259    fn spans_tokens_match_tokenize() {
260        for s in [
261            "rust full text search",
262            "全文检索引擎 rust 実装",
263            "Rust搜索engine引擎x",
264            "a I v2.7-alpha",
265        ] {
266            let plain = tokenize(s.as_bytes());
267            let with_spans: Vec<Vec<u8>> =
268                tokenize_spans(s.as_bytes()).into_iter().map(|(t, _, _)| t).collect();
269            assert_eq!(plain, with_spans, "token stream diverged for {s:?}");
270        }
271    }
272
273    #[test]
274    fn spans_invalid_utf8_is_empty() {
275        assert!(tokenize_spans(&[0xFF, 0xFE, b'o', b'k']).is_empty());
276    }
277}