1pub(crate) mod fold;
24mod fold_table;
25pub(crate) mod numbers;
26mod pipeline;
27pub(crate) use pipeline::ControlledToken;
28pub mod profiles;
29#[cfg(test)]
30mod properties;
31pub(crate) mod segment;
32pub(crate) mod stemmer;
33pub mod stopwords;
34pub mod vocab;
35
36pub use profiles::Profile;
37pub use stopwords::StopwordList;
38pub use vocab::{MAX_SURFACE_TERMS, VocabError, Vocabulary};
39
40#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
45pub struct TokenFlags(u8);
46
47impl TokenFlags {
48 pub const NO_FUZZY: Self = Self(1);
52 pub const VARIANT: Self = Self(2);
58
59 #[must_use]
61 pub const fn empty() -> Self {
62 Self(0)
63 }
64
65 #[must_use]
67 pub const fn bits(self) -> u8 {
68 self.0
69 }
70
71 #[must_use]
73 pub const fn contains(self, other: Self) -> bool {
74 self.0 & other.0 == other.0
75 }
76
77 #[must_use]
79 pub const fn union(self, other: Self) -> Self {
80 Self(self.0 | other.0)
81 }
82}
83
84#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
86pub struct TokenOffset {
87 pub start: u32,
89 pub end: u32,
91}
92
93impl TokenOffset {
94 #[must_use]
99 pub fn slice<'text>(&self, text: &'text str) -> Option<&'text str> {
100 let start = usize::try_from(self.start).ok()?;
101 let end = usize::try_from(self.end).ok()?;
102 text.get(start..end)
103 }
104}
105
106#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
108pub struct Token {
109 pub term: String,
111 pub position: u32,
113 pub offset: TokenOffset,
115 pub flags: TokenFlags,
117}
118
119#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
121#[repr(u16)]
122pub enum Segmenter {
123 IdentifierPreserving = 1,
125}
126
127#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
129#[repr(u16)]
130pub enum FoldingForm {
131 None = 0,
133 NfkcSearchFold = 1,
135}
136
137#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
139#[repr(u16)]
140pub enum Stemmer {
141 None = 0,
143 EnglishPorter2 = 1,
145}
146
147#[derive(Clone, Debug, Eq, PartialEq)]
149pub enum TokenizerError {
150 InvalidUtf8 {
152 offset: usize,
154 },
155 TextTooLong {
157 bytes: usize,
159 limit: usize,
161 },
162 Vocab(VocabError),
164}
165
166impl std::fmt::Display for TokenizerError {
167 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168 match self {
169 Self::InvalidUtf8 { offset } => {
170 write!(formatter, "input is not valid UTF-8 at byte {offset}")
171 }
172 Self::TextTooLong { bytes, limit } => write!(
173 formatter,
174 "analyzed text is {bytes} bytes, over the {limit}-byte limit"
175 ),
176 Self::Vocab(error) => write!(formatter, "vocabulary rejected: {error}"),
177 }
178 }
179}
180
181impl std::error::Error for TokenizerError {}
182
183impl From<VocabError> for TokenizerError {
184 fn from(error: VocabError) -> Self {
185 Self::Vocab(error)
186 }
187}
188
189pub const MAX_TEXT_BYTES: usize = u32::MAX as usize;
196
197#[derive(Clone, Debug, Eq, PartialEq)]
202pub struct TokenizerConfig {
203 pub profile: Profile,
205 pub segmenter: Segmenter,
207 pub folding: FoldingForm,
209 pub stopwords: StopwordList,
211 pub stemmer: Stemmer,
213 pub decompose_words: bool,
215 pub emit_catenation: bool,
217 pub number_words: bool,
219 pub drop_single_char_parts: bool,
232 pub vocabulary: Vocabulary,
234}
235
236impl TokenizerConfig {
237 #[must_use]
239 pub fn text_default() -> Self {
240 Profile::TextDefault.config()
241 }
242
243 #[must_use]
245 pub fn code() -> Self {
246 Profile::Code.config()
247 }
248
249 #[must_use]
251 pub fn voice() -> Self {
252 Profile::Voice.config()
253 }
254
255 #[must_use]
257 pub fn with_vocabulary(mut self, vocabulary: Vocabulary) -> Self {
258 self.vocabulary = vocabulary;
259 self
260 }
261
262 #[must_use]
264 pub fn epoch(&self) -> TokenizerEpoch {
265 TokenizerEpoch::of(self)
266 }
267}
268
269#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
273pub struct TokenizerEpoch(u64);
274
275const EPOCH_MAGIC: &[u8; 8] = b"ZETOKEP1";
277
278impl TokenizerEpoch {
279 pub(crate) const fn from_value(value: u64) -> Self {
280 Self(value)
281 }
282
283 #[must_use]
298 pub fn of(config: &TokenizerConfig) -> Self {
299 let mut input: Vec<u8> = Vec::with_capacity(256);
300 input.extend_from_slice(EPOCH_MAGIC);
301 push_u16(&mut input, config.segmenter as u16);
302 push_u16(&mut input, config.folding as u16);
303 push_u16(&mut input, fold::FOLDING_TABLE_VERSION);
304 push_str(&mut input, fold::FOLDING_UNICODE_VERSION);
305 push_u16(&mut input, config.stopwords.id());
306 push_u32(
307 &mut input,
308 u32::try_from(config.stopwords.terms().len()).unwrap_or(u32::MAX),
309 );
310 push_u16(&mut input, config.stemmer as u16);
311 input.push(u8::from(config.decompose_words));
312 input.push(u8::from(config.emit_catenation));
313 input.push(u8::from(config.number_words));
314 input.push(u8::from(config.drop_single_char_parts));
315 push_u16(&mut input, config.profile.id());
316 push_u32(
317 &mut input,
318 u32::try_from(config.vocabulary.len()).unwrap_or(u32::MAX),
319 );
320 for (surface, canonical) in config.vocabulary.entries() {
321 push_u32(&mut input, u32::try_from(surface.len()).unwrap_or(u32::MAX));
322 for term in surface {
323 push_str(&mut input, term);
324 }
325 push_str(&mut input, canonical);
326 }
327 Self(xxhash_rust::xxh3::xxh3_64(&input))
328 }
329
330 #[must_use]
332 pub const fn value(self) -> u64 {
333 self.0
334 }
335
336 #[must_use]
338 pub fn to_hex(self) -> String {
339 format!("{:016x}", self.0)
340 }
341}
342
343impl std::fmt::Display for TokenizerEpoch {
344 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
345 write!(formatter, "{:016x}", self.0)
346 }
347}
348
349fn push_u16(buffer: &mut Vec<u8>, value: u16) {
350 buffer.extend_from_slice(&value.to_le_bytes());
351}
352
353fn push_u32(buffer: &mut Vec<u8>, value: u32) {
354 buffer.extend_from_slice(&value.to_le_bytes());
355}
356
357fn push_str(buffer: &mut Vec<u8>, value: &str) {
358 push_u32(buffer, u32::try_from(value.len()).unwrap_or(u32::MAX));
359 buffer.extend_from_slice(value.as_bytes());
360}
361
362#[derive(Clone, Debug)]
367pub struct Analyzer {
368 config: TokenizerConfig,
369 epoch: TokenizerEpoch,
370}
371
372impl Analyzer {
373 pub fn new(config: TokenizerConfig) -> Result<Self, TokenizerError> {
379 let epoch = TokenizerEpoch::of(&config);
380 Ok(Self { config, epoch })
381 }
382
383 #[must_use]
385 pub const fn epoch(&self) -> TokenizerEpoch {
386 self.epoch
387 }
388
389 #[must_use]
391 pub const fn config(&self) -> &TokenizerConfig {
392 &self.config
393 }
394
395 #[must_use]
399 pub fn analyze(&self, text: &str) -> Vec<Token> {
400 pipeline::analyze(&self.config, text)
401 }
402
403 pub(crate) fn analyze_with_policy<'m, P: super::control::BuildPolicy<'m>>(
404 &self,
405 text: &str,
406 policy: &mut P,
407 ) -> Result<pipeline::ControlledTokens<'m, P::Charge>, P::Error>
408 where
409 P::Error: From<TokenizerError>,
410 {
411 if text.len() > MAX_TEXT_BYTES {
412 return Err(P::Error::from(TokenizerError::TextTooLong {
413 bytes: text.len(),
414 limit: MAX_TEXT_BYTES,
415 }));
416 }
417 pipeline::analyze_with_policy(&self.config, text, policy)
418 }
419
420 pub fn analyze_bytes(&self, bytes: &[u8]) -> Result<Vec<Token>, TokenizerError> {
428 if bytes.len() > MAX_TEXT_BYTES {
429 return Err(TokenizerError::TextTooLong {
430 bytes: bytes.len(),
431 limit: MAX_TEXT_BYTES,
432 });
433 }
434 let text = std::str::from_utf8(bytes).map_err(|error| TokenizerError::InvalidUtf8 {
435 offset: error.valid_up_to(),
436 })?;
437 Ok(self.analyze(text))
438 }
439}
440
441#[cfg(test)]
442#[allow(
443 clippy::expect_used,
444 clippy::indexing_slicing,
445 clippy::panic,
446 clippy::unwrap_used
447)]
448mod tests {
449 use super::*;
450
451 fn analyzer() -> Analyzer {
452 Analyzer::new(TokenizerConfig::text_default()).expect("valid config")
453 }
454
455 fn terms(tokens: &[Token]) -> Vec<&str> {
456 tokens.iter().map(|token| token.term.as_str()).collect()
457 }
458
459 #[test]
460 fn invalid_utf8_is_rejected_with_a_typed_error_at_the_boundary() {
461 let error = analyzer()
462 .analyze_bytes(&[b'o', b'k', 0xFF, 0xFE])
463 .expect_err("invalid UTF-8 must be refused");
464 assert_eq!(error, TokenizerError::InvalidUtf8 { offset: 2 });
465 }
466
467 #[test]
468 fn valid_utf8_bytes_analyze_identically_to_the_string_path() {
469 let text = "Café put_if_match";
470 let analyzer = analyzer();
471 assert_eq!(
472 analyzer
473 .analyze_bytes(text.as_bytes())
474 .expect("valid UTF-8"),
475 analyzer.analyze(text)
476 );
477 }
478
479 #[test]
480 fn changing_any_pipeline_knob_changes_the_epoch_digest() {
481 let base = TokenizerConfig::text_default();
482 let baseline = base.epoch();
483
484 let mut changed = base.clone();
485 changed.stemmer = Stemmer::None;
486 assert_ne!(changed.epoch(), baseline, "stemmer must affect the epoch");
487
488 let mut changed = base.clone();
489 changed.stopwords = StopwordList::None;
490 assert_ne!(changed.epoch(), baseline, "stopwords must affect the epoch");
491
492 let mut changed = base.clone();
493 changed.folding = FoldingForm::None;
494 assert_ne!(changed.epoch(), baseline, "folding must affect the epoch");
495
496 let mut changed = base.clone();
497 changed.decompose_words = !changed.decompose_words;
498 assert_ne!(changed.epoch(), baseline, "decomposition must affect it");
499
500 let mut changed = base.clone();
501 changed.emit_catenation = !changed.emit_catenation;
502 assert_ne!(changed.epoch(), baseline, "catenation must affect it");
503
504 let mut changed = base.clone();
505 changed.number_words = !changed.number_words;
506 assert_ne!(changed.epoch(), baseline, "number words must affect it");
507
508 let mut changed = base;
509 changed.profile = Profile::Code;
510 assert_ne!(changed.epoch(), baseline, "profile must affect the epoch");
511 }
512
513 #[test]
514 fn removing_a_vocab_entry_changes_the_epoch_digest() {
515 let mut vocabulary = Vocabulary::new();
516 vocabulary
517 .declare("put_if_match", &[&["put", "if", "match"][..]])
518 .expect("valid declaration");
519 let with_entry = TokenizerConfig::text_default().with_vocabulary(vocabulary.clone());
520 let populated = with_entry.epoch();
521
522 vocabulary.remove("put_if_match");
523 let without_entry = TokenizerConfig::text_default().with_vocabulary(vocabulary);
524 assert_ne!(populated, without_entry.epoch());
525 assert_eq!(
526 without_entry.epoch(),
527 TokenizerConfig::text_default().epoch()
528 );
529 }
530
531 #[test]
532 fn the_epoch_does_not_depend_on_vocabulary_insertion_order() {
533 let mut first = Vocabulary::new();
534 first
535 .declare("alpha", &[&["a", "one"][..]])
536 .expect("valid declaration");
537 first
538 .declare("beta", &[&["b", "two"][..]])
539 .expect("valid declaration");
540
541 let mut second = Vocabulary::new();
542 second
543 .declare("beta", &[&["b", "two"][..]])
544 .expect("valid declaration");
545 second
546 .declare("alpha", &[&["a", "one"][..]])
547 .expect("valid declaration");
548
549 assert_eq!(
550 TokenizerConfig::text_default()
551 .with_vocabulary(first)
552 .epoch(),
553 TokenizerConfig::text_default()
554 .with_vocabulary(second)
555 .epoch()
556 );
557 }
558
559 #[test]
560 fn the_epoch_hex_form_is_stable_and_sixteen_digits() {
561 let epoch = TokenizerConfig::text_default().epoch();
562 let hex = epoch.to_hex();
563 assert_eq!(hex.len(), 16);
564 assert_eq!(hex, epoch.to_string());
565 assert!(hex.chars().all(|value| value.is_ascii_hexdigit()));
566 }
567
568 #[test]
569 fn adding_a_vocab_entry_unifies_all_three_surface_forms_at_one_position() {
570 let mut vocabulary = Vocabulary::new();
571 vocabulary
572 .declare(
573 "put_if_match",
574 &[&["put", "if", "match"][..], &["putifmatch"][..]],
575 )
576 .expect("valid declaration");
577 let analyzer = Analyzer::new(TokenizerConfig::text_default().with_vocabulary(vocabulary))
578 .expect("valid config");
579
580 for surface in ["put_if_match", "put if match", "putifmatch"] {
581 let tokens = analyzer.analyze(surface);
582 let canonical = tokens
583 .iter()
584 .find(|token| token.term == "put_if_match")
585 .unwrap_or_else(|| panic!("{surface:?} did not emit the canonical term"));
586 assert_eq!(
587 canonical.position, 0,
588 "{surface:?} emitted the canonical term off position zero"
589 );
590 }
591 }
592
593 #[test]
594 fn identifier_and_number_tokens_carry_the_no_fuzzy_flag() {
595 let tokens = analyzer().analyze("ticket i-485 filed 2026 by anup");
596 for token in &tokens {
597 let expected =
598 token.term.contains('-') || token.term.chars().any(|value| value.is_ascii_digit());
599 if expected {
600 assert!(
601 token.flags.contains(TokenFlags::NO_FUZZY),
602 "{:?} must be flagged no_fuzzy",
603 token.term
604 );
605 }
606 }
607 assert!(tokens.iter().any(|token| token.term == "i-485"));
608 let numeral = analyzer().analyze("\u{2160}");
609 assert_eq!(numeral.len(), 1);
610 assert!(numeral[0].flags.contains(TokenFlags::NO_FUZZY));
611 }
612
613 #[test]
614 fn plain_words_are_not_flagged_no_fuzzy() {
615 let tokens = analyzer().analyze("engine");
616 assert_eq!(terms(&tokens), vec!["engin"]);
617 assert!(
618 !tokens
619 .first()
620 .expect("one token")
621 .flags
622 .contains(TokenFlags::NO_FUZZY)
623 );
624 }
625
626 #[test]
627 fn oversized_input_is_refused_rather_than_truncated() {
628 let error = TokenizerError::TextTooLong {
631 bytes: MAX_TEXT_BYTES + 1,
632 limit: MAX_TEXT_BYTES,
633 };
634 assert!(error.to_string().contains("over the"));
635 }
636
637 #[test]
638 fn token_offsets_slice_back_to_the_surface_form() {
639 let text = "Café put_if_match";
640 for token in analyzer().analyze(text) {
641 assert!(
642 token.offset.slice(text).is_some(),
643 "offset for {:?} is not a character boundary",
644 token.term
645 );
646 }
647 }
648
649 #[test]
650 fn flag_algebra_is_consistent() {
651 let both = TokenFlags::NO_FUZZY.union(TokenFlags::VARIANT);
652 assert!(both.contains(TokenFlags::NO_FUZZY));
653 assert!(both.contains(TokenFlags::VARIANT));
654 assert!(!TokenFlags::NO_FUZZY.contains(TokenFlags::VARIANT));
655 assert!(TokenFlags::empty().bits() == 0);
656 assert!(both.contains(TokenFlags::empty()));
657 }
658}