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