Skip to main content

uqa_analysis/
tokenizer.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Tokenizers for the analysis pipeline. An [`Analyzer`] owns exactly one
8//! tokenizer.
9//!
10//! [`Analyzer`]: crate::analyzer::Analyzer
11
12use 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    /// Validate configuration without tokenizing input. This is used when an
44    /// analyzer is registered, while [`Self::tokenize`] repeats the checks so
45    /// deserialized legacy values can never bypass them.
46    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    /// Tokenize source text with explicit offsets, positions, and final source coordinates.
60    pub fn tokenize_with_offsets(&self, text: &str) -> AnalysisResult<crate::AnalyzedText> {
61        self.tokenize_mapped(&crate::FilteredText::new(text))
62    }
63
64    /// Tokenize with reservations for owned buffers and retained source provenance.
65    ///
66    /// Immutable configuration resources remain separately managed. Built-in word tokenizers and every prepared pattern search poll while scanning; regex fallback workspace reserves its buffers from the caller's allowance. Emission and projection also poll. Cloning the underlying analyzed value creates separate, unreserved token buffers.
67    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    /// Tokenize a retained character-filter result under a caller allowance.
77    ///
78    /// Previously prepared source allocations keep their existing owner. Newly built coordinate indexes, token buffers, terms, and retained source copies use `budget`; failed calls leave the input's coordinate caches unchanged.
79    ///
80    /// ```
81    /// use uqa_analysis::{CharFilter, Tokenizer};
82    /// use uqa_core::memory::MemoryBudget;
83    /// let budget = MemoryBudget::new(16 * 1024);
84    /// let filtered = CharFilter::HTMLStrip.filter_with_offsets_budgeted(
85    ///     "<b>韓🙂</b>", &budget, &mut || Ok(()),
86    /// )?;
87    /// let tokens = Tokenizer::Whitespace.tokenize_mapped_budgeted(
88    ///     &filtered, &budget, || Ok(()),
89    /// )?;
90    /// assert_eq!(tokens.tokens()[0].term(), "韓🙂");
91    /// assert_eq!(tokens.tokens()[0].offsets().unwrap().utf8, 3..10);
92    /// drop(filtered);
93    /// assert!(budget.used() > 0);
94    /// drop(tokens);
95    /// assert_eq!(budget.used(), 0);
96    /// # Ok::<(), uqa_analysis::AnalysisError>(())
97    /// ```
98    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        // "ab" word: 2-grams [ab]
178        // "abc" word: 2-grams [ab, bc], 3-grams [abc]
179        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}