Skip to main content

plugmem_core/tokenizer/
emit.rs

1//! Canonical token emission and byte-budget handling.
2
3use super::{tables::is_word_joiner, unicode::UnicodeBackend};
4
5/// Upper bound on an emitted token, in bytes.
6pub const MAX_TOKEN_BYTES: usize = 64;
7
8/// Sends the assembled token, truncated to [`MAX_TOKEN_BYTES`] at a char
9/// boundary. Unicode marks and word joiners are meaningful only after a
10/// lexical base, so leading marks and joiners are removed without allocating.
11/// Keeping either would make re-tokenization context-sensitive (for example,
12/// `\u{300}word` → `word`, `_word` → `word`, and `word.` → `word`). A mark-only
13/// token is retained when it has Unicode Alphabetic semantics; this keeps
14/// valid standalone script marks such as U+0F71 searchable. Empty and
15/// non-lexical tokens are guarded against rather than asserted.
16pub(super) fn emit_truncated(token: &str, sink: &mut dyn FnMut(&str)) {
17    let token = trim_leading_contextual_chars(token);
18    // Some Unicode marks carry the `Alphabetic` property and are
19    // valid standalone search tokens (for example U+0F71). Others, such as
20    // U+0300, are only an Extend character: ICU will not tokenize them when
21    // presented alone. Never emit a mark-only/non-alphabetic token, because
22    // it cannot satisfy the tokenizer's fixed-point contract.
23    if token.is_empty()
24        || !token
25            .chars()
26            .any(|c| c.is_alphanumeric() || UnicodeBackend::is_alphabetic(c))
27    {
28        return;
29    }
30    let mut end = token.len().min(MAX_TOKEN_BYTES);
31    while !token.is_char_boundary(end) {
32        end -= 1;
33    }
34
35    // A mark adjacent to a trailing joiner is not lexical content of the
36    // token: in isolation UAX #29 breaks it away after the joiner is trimmed.
37    // Remove the whole contextual suffix, regardless of whether the mark or
38    // joiner comes last (`word_◌`, `word◌_`, or `word_◌_`). Marks following a
39    // real base without a trailing joiner are retained.
40    let original_end = end;
41    let mut saw_trailing_joiner = false;
42    while end > 0 {
43        let Some(c) = token[..end].chars().next_back() else {
44            break;
45        };
46        if is_word_joiner(c) {
47            saw_trailing_joiner = true;
48            end -= c.len_utf8();
49        } else if UnicodeBackend::is_mark(c) {
50            end -= c.len_utf8();
51        } else {
52            if !saw_trailing_joiner {
53                end = original_end;
54            }
55            break;
56        }
57    }
58    if end == 0 && !saw_trailing_joiner {
59        end = original_end;
60    }
61    if end == 0 {
62        return;
63    }
64    sink(&token[..end]);
65}
66
67/// Removes Unicode marks that precede a lexical base without allocating.
68///
69/// A leading mark can be attached to a preceding segment by UAX #29, while
70/// retokenizing the emitted string sees it as a standalone prefix. Removing
71/// that prefix makes emission independent of the surrounding input. A token
72/// made entirely of a Unicode-alphabetic mark is kept because it
73/// is a valid standalone token for scripts that use such marks as letters.
74fn trim_leading_marks(token: &str) -> &str {
75    let mut prefix = 0usize;
76    for (offset, c) in token.char_indices() {
77        if UnicodeBackend::is_mark(c) {
78            prefix = offset + c.len_utf8();
79        } else {
80            break;
81        }
82    }
83    // Leave a mark-only token untouched. The lexical-content guard in the
84    // caller decides whether that standalone mark is meaningful.
85    if prefix == token.len() {
86        token
87    } else {
88        &token[prefix..]
89    }
90}
91
92/// Removes any sequence of leading marks and word joiners. The loop matters
93/// for inputs such as `'.' + U+0300 + 'o'`: removing the joiner exposes a
94/// leading mark that must be removed in the next pass.
95fn trim_leading_contextual_chars(mut token: &str) -> &str {
96    loop {
97        let next = trim_leading_marks(token).trim_start_matches(is_word_joiner);
98        if next.len() == token.len() {
99            return token;
100        }
101        token = next;
102    }
103}