uqa_analysis/
tokenizer.rs1use serde::{Deserialize, Serialize};
13use std::sync::OnceLock;
14use uqa_core::memory::{Budgeted, MemoryBudget};
15
16use crate::character_class::class;
17use crate::error::{AnalysisError, AnalysisResult};
18
19mod compiled;
20mod stream;
21pub(crate) use compiled::PreparedTokenizer;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(tag = "type", rename_all = "snake_case")]
25pub enum Tokenizer {
26 Whitespace,
27 Standard,
28 Letter,
29 NGram {
30 min_gram: usize,
31 max_gram: usize,
32 },
33 Pattern {
34 pattern: String,
35 },
36 Keyword,
37 #[cfg(feature = "nori")]
38 #[serde(rename = "nori_tokenizer")]
39 Nori(crate::nori::NoriTokenizerConfig),
40}
41
42impl Tokenizer {
43 pub fn validate(&self) -> AnalysisResult<()> {
47 match self {
48 #[cfg(feature = "nori")]
49 Tokenizer::Nori(_) => self.prepare().map(|_| ()),
50 Tokenizer::NGram { .. } | Tokenizer::Pattern { .. } => self.prepare().map(|_| ()),
51 _ => Ok(()),
52 }
53 }
54
55 pub fn tokenize(&self, text: &str) -> AnalysisResult<Vec<String>> {
56 self.tokenize_with_offsets(text)?.into_terms()
57 }
58
59 pub fn tokenize_with_offsets(&self, text: &str) -> AnalysisResult<crate::AnalyzedText> {
61 self.tokenize_mapped(&crate::FilteredText::new(text))
62 }
63
64 pub fn tokenize_with_offsets_budgeted(
68 &self,
69 text: &str,
70 budget: &MemoryBudget,
71 poll: impl FnMut() -> AnalysisResult<()>,
72 ) -> AnalysisResult<Budgeted<crate::AnalyzedText>> {
73 self.tokenize_mapped_budgeted(&crate::FilteredText::new(text), budget, poll)
74 }
75
76 pub fn tokenize_mapped_budgeted(
99 &self,
100 text: &crate::FilteredText<'_>,
101 budget: &MemoryBudget,
102 mut poll: impl FnMut() -> AnalysisResult<()>,
103 ) -> AnalysisResult<Budgeted<crate::AnalyzedText>> {
104 poll()?;
105 self.prepare()?
106 .tokenize_mapped_budgeted(text, budget, &mut poll)
107 }
108
109 pub(crate) fn tokenize_mapped(
110 &self,
111 text: &crate::FilteredText<'_>,
112 ) -> AnalysisResult<crate::AnalyzedText> {
113 self.prepare()?.tokenize_mapped(text)
114 }
115}
116
117fn validate_gram_bounds(
118 component: &'static str,
119 min_gram: usize,
120 max_gram: usize,
121) -> AnalysisResult<()> {
122 if min_gram == 0 || max_gram < min_gram {
123 return Err(AnalysisError::InvalidGramBounds {
124 component,
125 min_gram,
126 max_gram,
127 });
128 }
129 Ok(())
130}
131
132pub(super) fn standard_word_class() -> AnalysisResult<&'static regex_syntax::hir::ClassUnicode> {
133 static CLASS: OnceLock<Result<regex_syntax::hir::ClassUnicode, String>> = OnceLock::new();
134 CLASS
135 .get_or_init(|| class(r"\w"))
136 .as_ref()
137 .map_err(|message| AnalysisError::BuiltInRegex {
138 component: "standard tokenizer",
139 message: message.clone(),
140 })
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn whitespace_splits_on_whitespace() {
149 let t = Tokenizer::Whitespace;
150 assert_eq!(
151 t.tokenize("hello world\n rust").unwrap(),
152 vec!["hello", "world", "rust"]
153 );
154 }
155
156 #[test]
157 fn standard_extracts_unicode_words() {
158 let t = Tokenizer::Standard;
159 assert_eq!(
160 t.tokenize("Rust 2024! Carácter.").unwrap(),
161 vec!["Rust", "2024", "Carácter"]
162 );
163 }
164
165 #[test]
166 fn letter_extracts_ascii_letters_only() {
167 let t = Tokenizer::Letter;
168 assert_eq!(t.tokenize("abc123 xyz").unwrap(), vec!["abc", "xyz"]);
169 }
170
171 #[test]
172 fn ngram_emits_substrings_per_word() {
173 let t = Tokenizer::NGram {
174 min_gram: 2,
175 max_gram: 3,
176 };
177 assert_eq!(t.tokenize("ab abc").unwrap(), vec!["ab", "ab", "bc", "abc"]);
180 }
181
182 #[test]
183 fn pattern_splits_on_regex() {
184 let t = Tokenizer::Pattern {
185 pattern: r"\W+".to_string(),
186 };
187 assert_eq!(t.tokenize("hello, world!").unwrap(), vec!["hello", "world"]);
188 }
189
190 #[test]
191 fn keyword_emits_whole_input() {
192 let t = Tokenizer::Keyword;
193 assert_eq!(t.tokenize("a b c").unwrap(), vec!["a b c"]);
194 assert!(t.tokenize("").unwrap().is_empty());
195 }
196
197 #[test]
198 fn round_trips_via_serde_json() {
199 let t = Tokenizer::NGram {
200 min_gram: 2,
201 max_gram: 4,
202 };
203 let s = serde_json::to_string(&t).unwrap();
204 let back: Tokenizer = serde_json::from_str(&s).unwrap();
205 assert_eq!(
206 back.tokenize("foobar").unwrap(),
207 t.tokenize("foobar").unwrap()
208 );
209 }
210}