1use std::ops::Range;
22
23use icu_properties::props::Script;
24use icu_properties::{CodePointMapData, CodePointMapDataBorrowed};
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub enum CharClass {
29 AsciiWord,
31 Han,
33 UnicodeWord,
35 Other,
37}
38
39pub fn classify_char(c: char) -> CharClass {
42 if c.is_ascii_alphanumeric() {
43 CharClass::AsciiWord
44 } else if is_han(c) {
45 CharClass::Han
46 } else if c.is_alphanumeric() {
47 CharClass::UnicodeWord
48 } else {
49 CharClass::Other
50 }
51}
52
53static HAN_SCRIPT_DATA: CodePointMapDataBorrowed<'static, Script> =
54 CodePointMapData::<Script>::new();
55
56fn is_han(c: char) -> bool {
57 HAN_SCRIPT_DATA.get(c) == Script::Han
58}
59
60pub fn has_irregular_lowercase(text: &str) -> bool {
66 !text.is_ascii() && text.contains(['\u{130}', '\u{212A}', 'Σ'])
67}
68
69pub fn tokenize(text: &str) -> Vec<&str> {
76 let mut tokens = Vec::new();
77 for_each_word_run(text, |class, range| {
78 let run = &text[range];
79 if class == CharClass::Han {
80 push_han_tokens(run, &mut tokens);
81 } else {
82 tokens.push(run);
83 }
84 });
85 tokens
86}
87
88fn for_each_word_run(text: &str, mut f: impl FnMut(CharClass, Range<usize>)) {
90 if text.is_ascii() {
91 let mut start = None;
94 for (i, b) in text.bytes().enumerate() {
95 match (b.is_ascii_alphanumeric(), start) {
96 (true, None) => start = Some(i),
97 (false, Some(s)) => {
98 f(CharClass::AsciiWord, s..i);
99 start = None;
100 }
101 _ => {}
102 }
103 }
104 if let Some(s) = start {
105 f(CharClass::AsciiWord, s..text.len());
106 }
107 return;
108 }
109
110 let mut run: Option<(CharClass, usize)> = None;
111 for (i, c) in text.char_indices() {
112 let class = classify_char(c);
113 if let Some((run_class, start)) = run {
114 if run_class == class {
115 continue;
116 }
117 f(run_class, start..i);
118 run = None;
119 }
120 if class != CharClass::Other {
121 run = Some((class, i));
122 }
123 }
124 if let Some((run_class, start)) = run {
125 f(run_class, start..text.len());
126 }
127}
128
129fn push_han_tokens<'a>(run: &'a str, tokens: &mut Vec<&'a str>) {
130 let mut starts = run.char_indices().map(|(i, _)| i).skip(1);
131 let Some(mut second) = starts.next() else {
132 tokens.push(run);
133 return;
134 };
135 let mut first = 0;
136 for next in starts.chain(std::iter::once(run.len())) {
137 tokens.push(&run[first..next]);
138 first = second;
139 second = next;
140 }
141}
142
143pub fn extract_term_probes(term: &str) -> Vec<String> {
146 let bounded = !term.chars().any(|c| classify_char(c) == CharClass::Han);
150 let mut probes = Vec::new();
151 push_literal_probes(term, bounded, bounded, &mut probes);
152 probes
153}
154
155pub fn extract_like_probes(pattern: &str) -> Vec<String> {
159 let mut probes = Vec::new();
160 let mut literal = String::new();
161 let mut anchored_left = true;
164 let mut chars = pattern.chars();
165 while let Some(c) = chars.next() {
166 match c {
167 '%' | '_' => {
168 push_literal_probes(&literal, anchored_left, false, &mut probes);
169 literal.clear();
170 anchored_left = false;
171 }
172 '\\' => literal.push(chars.next().unwrap_or('\\')),
173 c => literal.push(c),
174 }
175 }
176 push_literal_probes(&literal, anchored_left, true, &mut probes);
177 probes
178}
179
180fn push_literal_probes(
184 literal: &str,
185 left_bounded: bool,
186 right_bounded: bool,
187 probes: &mut Vec<String>,
188) {
189 for_each_word_run(literal, |class, range| {
190 let whole =
191 (range.start > 0 || left_bounded) && (range.end < literal.len() || right_bounded);
192 let run = &literal[range];
193 if class == CharClass::Han && run.chars().nth(1).is_some() {
194 let mut tokens = Vec::new();
196 push_han_tokens(run, &mut tokens);
197 probes.extend(tokens.into_iter().map(str::to_string));
198 } else if whole {
199 probes.push(run.to_string());
200 }
201 });
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn han_detection_uses_script_not_all_cjk() {
210 assert!(is_han('汉'));
211 assert!(is_han('\u{30000}'));
212 assert!(!is_han('あ'));
213 assert!(!is_han('한'));
214 }
215
216 #[test]
217 fn test_script_tokenizer() {
218 let cases: [(&str, &[&str]); 7] = [
219 ("hello_world __x__", &["hello", "world", "x"]),
220 ("trace_id=abc", &["trace", "id", "abc"]),
221 ("错误error日志", &["错误", "error", "日志"]),
222 ("连接timeout.5次", &["连接", "timeout", "5", "次"]),
223 ("用户user-123登录", &["用户", "user", "123", "登录"]),
224 ("中国农业银行", &["中国", "国农", "农业", "业银", "银行"]),
225 ("Größe日x", &["Gr", "öß", "e", "日", "x"]),
226 ];
227 for (text, expected) in cases {
228 assert_eq!(tokenize(text), expected, "text: {text}");
229 }
230 }
231
232 #[test]
233 fn test_irregular_lowercase_chars() {
234 for c in (0..=0x10FFFF).filter_map(char::from_u32) {
235 let lower = c.to_lowercase().collect::<Vec<_>>();
236 let regular = lower.len() == 1 && classify_char(lower[0]) == classify_char(c);
237 let expect_regular = !matches!(c, '\u{130}' | '\u{212A}');
238 assert_eq!(regular, expect_regular, "U+{:04X}", c as u32);
239 }
240 }
241
242 #[test]
243 fn test_term_probes() {
244 let cases: [(&str, &[&str]); 8] = [
245 ("world", &["world"]),
246 ("hello world", &["hello", "world"]),
247 ("/start", &["start"]),
248 ("naïve", &["na", "ï", "ve"]),
249 ("农业", &["农业"]),
252 ("农", &[]),
253 ("机号1888", &["机号"]),
254 ("x错误y日z", &["错误", "y", "日"]),
255 ];
256 for (term, expected) in cases {
257 assert_eq!(extract_term_probes(term), expected, "term: {term}");
258 }
259 }
260
261 #[test]
262 fn test_like_probes() {
263 let cases: [(&str, &[&str]); 11] = [
264 ("%timeout%", &[]),
265 ("% timeout %", &["timeout"]),
266 ("timeout%", &[]),
267 ("timeout: %", &["timeout"]),
268 ("%: timeout", &["timeout"]),
269 ("a_b c", &["c"]),
270 (r"trace\_id=%", &["trace", "id"]),
271 (r"100\% done", &["100", "done"]),
272 (r"x\\y", &["x", "y"]),
273 ("%错误error日%", &["错误", "error"]),
274 ("%错误 x%", &["错误"]),
275 ];
276 for (pattern, expected) in cases {
277 assert_eq!(extract_like_probes(pattern), expected, "pattern: {pattern}");
278 }
279 }
280}