Skip to main content

khive_text/
tokenizer.rs

1//! Tokenizer implementations: whitespace, CJK-character, keyword, identifier, unicode-word.
2
3use crate::identifier::{is_identifier, split_identifier};
4use crate::lang::is_cjk_char;
5use crate::Tokenizer;
6
7/// Splits on Unicode whitespace, trims leading/trailing ASCII punctuation from
8/// each token, and drops empty results.
9///
10/// See `crates/khive-text/docs/api/tokenizers.md`.
11#[derive(Debug, Default, Clone)]
12pub struct WhitespaceTokenizer;
13
14impl Tokenizer for WhitespaceTokenizer {
15    fn tokenize(&self, text: &str) -> Vec<String> {
16        text.split_whitespace()
17            .map(|w| {
18                w.trim_matches(|c: char| c.is_ascii_punctuation())
19                    .to_string()
20            })
21            .filter(|t| !t.is_empty())
22            .collect()
23    }
24}
25
26/// Emits each CJK character as its own token; non-CJK runs split on whitespace with ASCII punctuation stripped.
27///
28/// See `crates/khive-text/docs/api/tokenizers.md`.
29#[derive(Debug, Default, Clone)]
30pub struct CjkCharTokenizer;
31
32impl Tokenizer for CjkCharTokenizer {
33    fn tokenize(&self, text: &str) -> Vec<String> {
34        let mut tokens = Vec::new();
35        let mut latin_buf = String::new();
36
37        let flush = |buf: &mut String, out: &mut Vec<String>| {
38            for part in buf.split_whitespace() {
39                let p = part.trim_matches(|c: char| c.is_ascii_punctuation());
40                if !p.is_empty() {
41                    out.push(p.to_string());
42                }
43            }
44            buf.clear();
45        };
46
47        for ch in text.chars() {
48            if is_cjk_char(ch) {
49                flush(&mut latin_buf, &mut tokens);
50                tokens.push(ch.to_string());
51            } else {
52                latin_buf.push(ch);
53            }
54        }
55        flush(&mut latin_buf, &mut tokens);
56        tokens
57    }
58}
59
60/// Returns the entire (whitespace-trimmed) input as a single token.
61/// Empty input → empty vec.
62#[derive(Debug, Default, Clone)]
63pub struct KeywordTokenizer;
64
65impl Tokenizer for KeywordTokenizer {
66    fn tokenize(&self, text: &str) -> Vec<String> {
67        let t = text.trim();
68        if t.is_empty() {
69            vec![]
70        } else {
71            vec![t.to_string()]
72        }
73    }
74}
75
76/// Identifier-aware tokenizer: emits lowercased original + split parts for identifiers,
77/// falls back to `WhitespaceTokenizer` for plain words.
78///
79/// See `crates/khive-text/docs/api/tokenizers.md`.
80#[derive(Debug, Clone)]
81pub struct IdentifierTokenizer {
82    pub min_part_len: usize,
83}
84
85impl Default for IdentifierTokenizer {
86    fn default() -> Self {
87        Self { min_part_len: 1 }
88    }
89}
90
91impl Tokenizer for IdentifierTokenizer {
92    fn tokenize(&self, text: &str) -> Vec<String> {
93        let ws = WhitespaceTokenizer;
94        let mut result: Vec<String> = Vec::new();
95
96        for raw_word in text.split_whitespace() {
97            let word = raw_word.trim_matches(|c: char| c.is_ascii_punctuation());
98            if word.is_empty() {
99                continue;
100            }
101            if is_identifier(word) {
102                let lower = word.to_lowercase();
103                let parts = split_identifier(word, self.min_part_len);
104                result.push(lower.clone());
105                for part in parts {
106                    if part != lower {
107                        result.push(part);
108                    }
109                }
110            } else {
111                result.extend(ws.tokenize(word));
112            }
113        }
114        result
115    }
116}
117
118/// Splits on Unicode word boundaries using `unicode_segmentation`.
119///
120/// Enable with `features = ["unicode"]`.
121#[cfg(feature = "unicode")]
122#[derive(Debug, Default, Clone)]
123pub struct UnicodeWordTokenizer;
124
125#[cfg(feature = "unicode")]
126impl Tokenizer for UnicodeWordTokenizer {
127    fn tokenize(&self, text: &str) -> Vec<String> {
128        use unicode_segmentation::UnicodeSegmentation;
129        UnicodeSegmentation::unicode_words(text)
130            .map(|w| w.to_string())
131            .collect()
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn whitespace_normal() {
141        let t = WhitespaceTokenizer;
142        assert_eq!(t.tokenize("hello world"), vec!["hello", "world"]);
143    }
144
145    #[test]
146    fn whitespace_empty() {
147        assert!(WhitespaceTokenizer.tokenize("").is_empty());
148        assert!(WhitespaceTokenizer.tokenize("   ").is_empty());
149    }
150
151    #[test]
152    fn whitespace_strips_outer_punct() {
153        let t = WhitespaceTokenizer;
154        assert_eq!(t.tokenize("hello, world!"), vec!["hello", "world"]);
155        assert_eq!(t.tokenize("...foo..."), vec!["foo"]);
156    }
157
158    #[test]
159    fn whitespace_preserves_inner_punct() {
160        let t = WhitespaceTokenizer;
161        assert_eq!(t.tokenize("fine-tuning v1.2"), vec!["fine-tuning", "v1.2"]);
162    }
163
164    #[test]
165    fn whitespace_all_punct_dropped() {
166        assert!(WhitespaceTokenizer.tokenize("... , ...").is_empty());
167    }
168
169    #[test]
170    fn cjk_spec_example() {
171        let got = CjkCharTokenizer.tokenize("使用LoRA进行fine-tuning");
172        assert_eq!(got, vec!["使", "用", "LoRA", "进", "行", "fine-tuning"]);
173    }
174
175    #[test]
176    fn cjk_empty() {
177        assert!(CjkCharTokenizer.tokenize("").is_empty());
178    }
179
180    #[test]
181    fn cjk_only_cjk() {
182        assert_eq!(CjkCharTokenizer.tokenize("中文"), vec!["中", "文"]);
183    }
184
185    #[test]
186    fn cjk_only_latin() {
187        assert_eq!(
188            CjkCharTokenizer.tokenize("hello world"),
189            vec!["hello", "world"]
190        );
191    }
192
193    #[test]
194    fn cjk_punct_stripped_from_latin_run() {
195        let got = CjkCharTokenizer.tokenize("你好, world!");
196        assert_eq!(got, vec!["你", "好", "world"]);
197    }
198
199    #[test]
200    fn cjk_hangul_unigrams() {
201        assert_eq!(CjkCharTokenizer.tokenize("가나"), vec!["가", "나"]);
202    }
203
204    #[test]
205    fn keyword_whole_phrase() {
206        assert_eq!(
207            KeywordTokenizer.tokenize("attention is all you need"),
208            vec!["attention is all you need"]
209        );
210    }
211
212    #[test]
213    fn keyword_empty() {
214        assert!(KeywordTokenizer.tokenize("").is_empty());
215        assert!(KeywordTokenizer.tokenize("   ").is_empty());
216    }
217
218    #[test]
219    fn keyword_trims_outer_whitespace() {
220        assert_eq!(KeywordTokenizer.tokenize("  hello  "), vec!["hello"]);
221    }
222
223    #[test]
224    fn keyword_single_word() {
225        assert_eq!(KeywordTokenizer.tokenize("LoRA"), vec!["LoRA"]);
226    }
227
228    #[test]
229    fn identifier_spec_example() {
230        let t = IdentifierTokenizer::default();
231        // "LoRA" is_identifier → lora + lo + ra; "attention" is plain → pass-through
232        let got = t.tokenize("LoRA attention");
233        assert_eq!(got, vec!["lora", "lo", "ra", "attention"]);
234    }
235
236    #[test]
237    fn identifier_empty() {
238        assert!(IdentifierTokenizer::default().tokenize("").is_empty());
239    }
240
241    #[test]
242    fn identifier_plain_passthrough() {
243        assert_eq!(
244            IdentifierTokenizer::default().tokenize("attention"),
245            vec!["attention"]
246        );
247    }
248
249    #[test]
250    fn identifier_snake_case() {
251        let got = IdentifierTokenizer::default().tokenize("fine_tuning");
252        assert_eq!(got, vec!["fine_tuning", "fine", "tuning"]);
253    }
254
255    #[test]
256    fn identifier_kebab() {
257        let got = IdentifierTokenizer::default().tokenize("fine-tuning");
258        assert_eq!(got, vec!["fine-tuning", "fine", "tuning"]);
259    }
260
261    #[test]
262    fn identifier_min_part_len_filters_short() {
263        let t = IdentifierTokenizer { min_part_len: 3 };
264        // "LoRA" parts: "lo" (2), "ra" (2) — both below min=3
265        assert_eq!(t.tokenize("LoRA"), vec!["lora"]);
266    }
267
268    #[test]
269    fn identifier_punct_stripped() {
270        let got = IdentifierTokenizer::default().tokenize("LoRA,");
271        assert_eq!(got, vec!["lora", "lo", "ra"]);
272    }
273
274    #[test]
275    fn identifier_tokenizer_filters_unicode_min_part_len_by_chars() {
276        // "\u{4F60}" (你) is 1 char / 3 bytes; min_part_len: 2 must drop it
277        // by character count, not keep it via byte length.
278        let t = IdentifierTokenizer { min_part_len: 2 };
279        let got = t.tokenize("foo_\u{4F60}");
280        assert!(
281            !got.contains(&"\u{4F60}".to_string()),
282            "expected unicode single-char part to be filtered; got: {got:?}"
283        );
284    }
285
286    #[cfg(feature = "unicode")]
287    #[test]
288    fn unicode_word_basic() {
289        let got = UnicodeWordTokenizer.tokenize("Hello, world!");
290        assert_eq!(got, vec!["Hello", "world"]);
291    }
292
293    #[cfg(feature = "unicode")]
294    #[test]
295    fn unicode_word_empty() {
296        assert!(UnicodeWordTokenizer.tokenize("").is_empty());
297    }
298}