plugmem_core/tokenizer/
emit.rs1use super::{tables::is_word_joiner, unicode::UnicodeBackend};
4use unicode_normalization::char::canonical_combining_class;
5
6pub const MAX_TOKEN_BYTES: usize = 64;
8
9pub(super) fn emit_truncated(token: &str, sink: &mut dyn FnMut(&str)) {
19 let token = trim_leading_contextual_chars(token);
20 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 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
73fn 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 if prefix == token.len() {
92 token
93 } else {
94 &token[prefix..]
95 }
96}
97
98fn 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}