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};
4use unicode_normalization::char::canonical_combining_class;
5
6/// Upper bound on an emitted token, in bytes.
7pub const MAX_TOKEN_BYTES: usize = 64;
8
9/// Sends the assembled token, truncated to [`MAX_TOKEN_BYTES`] at a char
10/// boundary. Unicode marks and word joiners are meaningful only after a
11/// lexical base, so leading marks and joiners are removed without allocating.
12/// Keeping either would make re-tokenization context-sensitive (for example,
13/// `\u{300}word` → `word`, `_word` → `word`, and `word.` → `word`). A mark-only
14/// token is retained only when it is a single mark with Unicode Alphabetic
15/// semantics; this keeps valid standalone script marks such as U+0F71
16/// searchable without letting contextual mark/filler runs escape as tokens.
17/// Empty and non-lexical tokens are guarded against rather than asserted.
18pub(super) fn emit_truncated(token: &str, sink: &mut dyn FnMut(&str)) {
19    let token = trim_leading_contextual_chars(token);
20    // Some Unicode marks carry the `Alphabetic` property and are
21    // valid standalone search tokens (for example U+0F71). Others, such as
22    // U+0300, are only an Extend character: ICU will not tokenize them when
23    // presented alone. Never emit a mark-only/non-alphabetic token, because
24    // it cannot satisfy the tokenizer's fixed-point contract.
25    if token.is_empty() || !has_emit_lexical_content(token) {
26        return;
27    }
28    if let Some((start, end)) = invalid_apostrophe_joiner(token) {
29        emit_truncated(&token[..start], sink);
30        emit_truncated(&token[end..], sink);
31        return;
32    }
33    let mut end = token.len().min(MAX_TOKEN_BYTES);
34    while !token.is_char_boundary(end) {
35        end -= 1;
36    }
37
38    // A mark adjacent to a trailing joiner is not lexical content of the
39    // token: in isolation UAX #29 breaks it away after the joiner is trimmed.
40    // Remove the whole contextual suffix, regardless of whether the mark or
41    // joiner comes last (`word_◌`, `word◌_`, or `word_◌_`). Marks following a
42    // real base without a trailing joiner are retained.
43    let original_end = end;
44    let mut saw_trailing_joiner = false;
45    while end > 0 {
46        let Some(c) = token[..end].chars().next_back() else {
47            break;
48        };
49        if is_word_joiner(c) {
50            saw_trailing_joiner = true;
51            end -= c.len_utf8();
52        } else if UnicodeBackend::is_mark(c) {
53            end -= c.len_utf8();
54        } else {
55            if !saw_trailing_joiner {
56                end = original_end;
57            }
58            break;
59        }
60    }
61    if end == 0 && !saw_trailing_joiner {
62        end = original_end;
63    }
64    if end == 0 {
65        return;
66    }
67    if !has_emit_lexical_content(&token[..end]) {
68        return;
69    }
70    sink(&token[..end]);
71}
72
73/// Removes Unicode marks that precede a lexical base without allocating.
74///
75/// A leading mark can be attached to a preceding segment by UAX #29, while
76/// retokenizing the emitted string sees it as a standalone prefix. Removing
77/// that prefix makes emission independent of the surrounding input. A token
78/// made entirely of a Unicode-alphabetic mark is kept because it
79/// is a valid standalone token for scripts that use such marks as letters.
80fn trim_leading_marks(token: &str) -> &str {
81    let mut prefix = 0usize;
82    for (offset, c) in token.char_indices() {
83        if UnicodeBackend::is_mark(c) {
84            prefix = offset + c.len_utf8();
85        } else {
86            break;
87        }
88    }
89    // Leave a mark-only token untouched. The lexical-content guard in the
90    // caller decides whether that standalone mark is meaningful.
91    if prefix == token.len() {
92        token
93    } else {
94        &token[prefix..]
95    }
96}
97
98/// Removes any sequence of leading marks and word joiners. The loop matters
99/// for inputs such as `'.' + U+0300 + 'o'`: removing the joiner exposes a
100/// leading mark that must be removed in the next pass.
101fn trim_leading_contextual_chars(mut token: &str) -> &str {
102    loop {
103        let next = trim_leading_marks(token).trim_start_matches(is_word_joiner);
104        if next.len() == token.len() {
105            return token;
106        }
107        token = next;
108    }
109}
110
111fn invalid_apostrophe_joiner(token: &str) -> Option<(usize, usize)> {
112    let mut chars = token.char_indices().peekable();
113    while let Some((offset, c)) = chars.next() {
114        if c != '\'' && c != '\u{2019}' {
115            continue;
116        }
117        let left = previous_non_mark(&token[..offset]).is_some_and(is_letter);
118        let right = next_non_mark(chars.clone()).is_some_and(is_letter);
119        if !left || !right {
120            return Some((offset, offset + c.len_utf8()));
121        }
122    }
123    None
124}
125
126fn previous_non_mark(text: &str) -> Option<char> {
127    text.chars().rev().find(|&c| !UnicodeBackend::is_mark(c))
128}
129
130fn next_non_mark<'a>(chars: impl Iterator<Item = (usize, char)> + 'a) -> Option<char> {
131    chars.map(|(_, c)| c).find(|&c| !UnicodeBackend::is_mark(c))
132}
133
134fn is_letter(c: char) -> bool {
135    c.is_alphabetic() || UnicodeBackend::is_alphabetic(c)
136}
137
138fn has_emit_lexical_content(token: &str) -> bool {
139    let mut chars = token.chars();
140    let Some(first) = chars.next() else {
141        return false;
142    };
143    let mut only_one = true;
144    let mut all_marks = UnicodeBackend::is_mark(first);
145    if !all_marks && is_lexical_base(first) {
146        return true;
147    }
148    for c in chars {
149        only_one = false;
150        let is_mark = UnicodeBackend::is_mark(c);
151        all_marks &= is_mark;
152        if !is_mark && is_lexical_base(c) {
153            return true;
154        }
155    }
156    all_marks && only_one && first.is_alphanumeric() && canonical_combining_class(first) != 0
157}
158
159fn is_lexical_base(c: char) -> bool {
160    (c.is_alphanumeric() || UnicodeBackend::is_alphabetic(c)) && !UnicodeBackend::is_mark(c)
161}