1use std::str;
8
9const MAX_PREFIX_LEN: usize = 40;
10const MAX_GENERIC_PREFIX_CHARS: usize = 4;
11const LOWERCASE_BUFFER_LEN: usize = 64;
12const URL_SCHEME_SEPARATOR: &str = "://";
13
14fn is_re_prefix(prefix: &str) -> bool {
15 hashify::set! {prefix.as_bytes(),
16 "re",
17 "res",
18 "resp",
19 "resposta",
20 "respuesta",
21 "rsp",
22 "ref",
23 "réf",
24 "rép",
25 "rep",
26 "réponse",
27 "reponse",
28 "aw",
29 "antw",
30 "antwort",
31 "antwoord",
32 "sv",
33 "svar",
34 "svara",
35 "vast",
36 "vastaus",
37 "vastus",
38 "vá",
39 "va",
40 "válasz",
41 "valasz",
42 "r",
43 "ri",
44 "rif",
45 "odp",
46 "odpowiedź",
47 "odpowiedz",
48 "odpov",
49 "odg",
50 "odgovor",
51 "απ",
52 "σχετ",
53 "απάντηση",
54 "ynt",
55 "yn",
56 "yan",
57 "yanıt",
58 "yanit",
59 "atb",
60 "atb.",
61 "atbilde",
62 "ateb",
63 "ats",
64 "ats.",
65 "atsakymas",
66 "bls",
67 "balas",
68 "השב",
69 "תשובה",
70 "رد",
71 "پاسخ",
72 "отв",
73 "ответ",
74 "відп",
75 "відповідь",
76 "отг",
77 "отговор",
78 "回复",
79 "回覆",
80 "答复",
81 "答覆",
82 "返信",
83 "회신",
84 "답장",
85 "ตอบกลับ",
86 "பதில்",
87 "trả lời",
88 }
89}
90
91fn is_fwd_prefix(prefix: &str) -> bool {
92 hashify::set! {prefix.as_bytes(),
93 "fwd",
94 "fw",
95 "rv",
96 "reenviado",
97 "enc",
98 "encaminhado",
99 "tr",
100 "transfert",
101 "wg",
102 "weitergeleitet",
103 "doorst",
104 "doorgestuurd",
105 "vs",
106 "videresendt",
107 "vb",
108 "vidarebefordrat",
109 "vl",
110 "välitetty",
111 "ed",
112 "edastatud",
113 "fs",
114 "framsenda",
115 "i",
116 "inoltro",
117 "inoltrato",
118 "pd",
119 "podaj dalej",
120 "przekazane",
121 "prosl",
122 "proslijeđeno",
123 "πρθ",
124 "προωθ",
125 "προωθημένο",
126 "i\u{307}lt",
127 "ilt",
128 "i\u{307}let",
129 "ilet",
130 "i\u{307}letilen",
131 "iletilen",
132 "yml",
133 "ymlaen",
134 "pārs",
135 "pārs.",
136 "pārsūtīts",
137 "persiųsta",
138 "trs",
139 "terusan",
140 "teruskan",
141 "továbbítás",
142 "הועבר",
143 "העברה",
144 "إعادة توجيه",
145 "перес",
146 "переслано",
147 "пересылка",
148 "пре",
149 "препратено",
150 "转发",
151 "转寄",
152 "轉寄",
153 "転送",
154 "전달",
155 "ส่งต่อ",
156 "முன்னனுப்பு",
157 "chuyển tiếp",
158 }
159}
160
161fn is_known_prefix(prefix: &str) -> bool {
162 is_re_prefix(prefix) || is_fwd_prefix(prefix)
163}
164
165fn is_generic_prefix(prefix: &str) -> bool {
166 let mut char_count = 0;
167
168 for ch in prefix.chars() {
169 if !ch.is_alphabetic() {
170 return false;
171 }
172 char_count += 1;
173 if char_count > MAX_GENERIC_PREFIX_CHARS {
174 return false;
175 }
176 }
177
178 char_count > 0
179}
180
181fn with_lowercase<T>(text: &str, f: impl FnOnce(&str) -> T) -> T {
182 let mut buffer = [0u8; LOWERCASE_BUFFER_LEN];
183 match buffer.get_mut(..text.len()) {
184 Some(lower) if text.is_ascii() => {
185 lower.copy_from_slice(text.as_bytes());
186 lower.make_ascii_lowercase();
187 f(str::from_utf8(lower).unwrap_or_default())
188 }
189 _ => f(&text.to_lowercase()),
190 }
191}
192
193fn is_prefix(text: &str, token_start: usize, token_end: usize, separator: usize) -> bool {
194 let token = text.get(token_start..token_end).unwrap_or_default();
195 let is_token_prefix = with_lowercase(token, |token| {
196 if is_known_prefix(token) {
197 Some(true)
198 } else if token_end == separator {
199 Some(
200 !text
201 .get(separator..)
202 .unwrap_or_default()
203 .starts_with(URL_SCHEME_SEPARATOR)
204 && is_generic_prefix(token),
205 )
206 } else {
207 None
208 }
209 });
210 is_token_prefix.unwrap_or_else(|| {
211 let span = text
212 .get(token_start..separator)
213 .unwrap_or_default()
214 .trim_end();
215 span.len() <= MAX_PREFIX_LEN && with_lowercase(span, is_known_prefix)
216 })
217}
218
219pub fn thread_name(text: &str) -> &str {
230 let mut token_start = 0;
231 let mut token_end = 0;
232
233 let mut thread_name_start = 0;
234 let mut fwd_start = 0;
235 let mut fwd_end = 0;
236 let mut last_blob_end = 0;
237
238 let mut in_blob = false;
239 let mut in_blob_ignore = false;
240 let mut seen_header = false;
241 let mut seen_blob_header = false;
242 let mut token_found = false;
243
244 for (pos, ch) in text.char_indices() {
245 match ch {
246 '[' => {
247 if !in_blob {
248 if token_found {
249 if token_end == 0 {
250 token_end = pos;
251 }
252 if is_prefix(text, token_start, token_end, pos) {
253 seen_header = true;
254 } else {
255 break;
256 }
257 }
258 token_found = false;
259 in_blob = true;
260 } else {
261 break;
262 }
263 }
264 ']' if in_blob => {
265 if seen_blob_header && token_found {
266 fwd_start = token_start;
267 fwd_end = pos;
268 }
269 if !seen_header {
270 last_blob_end = pos + 1;
271 }
272 in_blob = false;
273 token_found = false;
274 seen_blob_header = false;
275 in_blob_ignore = false;
276 }
277 ':' if !in_blob => {
278 if (seen_header && token_found) || (!seen_header && !token_found) {
279 break;
280 } else if !seen_header {
281 if token_end == 0 {
282 token_end = pos;
283 }
284 if !is_prefix(text, token_start, token_end, pos) {
285 break;
286 }
287 } else {
288 seen_header = false;
289 }
290 thread_name_start = pos + 1;
291 token_found = false;
292 }
293 ':' if in_blob && !in_blob_ignore => {
294 if token_end == 0 {
295 token_end = pos;
296 }
297
298 let token = text.get(token_start..token_end).unwrap_or_default();
299 with_lowercase(token, |prefix| {
300 if is_fwd_prefix(prefix) {
301 token_found = false;
302 seen_blob_header = true;
303 } else if seen_blob_header && is_re_prefix(prefix) {
304 token_found = false;
305 } else {
306 in_blob_ignore = true;
307 }
308 });
309 }
310 _ if ch.is_whitespace() => {
311 if token_end == 0 {
312 token_end = pos;
313 }
314 }
315 _ => {
316 if !token_found {
317 token_start = pos;
318 token_end = 0;
319 token_found = true;
320 } else if !in_blob && pos - token_start > MAX_PREFIX_LEN {
321 break;
322 }
323 }
324 }
325 }
326
327 if last_blob_end > thread_name_start
328 || (fwd_start > 0 && last_blob_end > fwd_start && fwd_start > thread_name_start)
329 {
330 let result = trim_trailing_fwd(text.get(last_blob_end..).unwrap_or_default());
331 if !result.is_empty() {
332 return result;
333 }
334 }
335
336 if fwd_start > 0 && thread_name_start < fwd_start {
337 let result = trim_trailing_fwd(text.get(fwd_start..fwd_end).unwrap_or_default());
338 if !result.is_empty() {
339 return result;
340 }
341 }
342
343 trim_trailing_fwd(text.get(thread_name_start..).unwrap_or_default())
344}
345
346fn trim_trailing_fwd(text: &str) -> &str {
347 let mut in_parentheses = false;
348 let mut trim_end = true;
349
350 let mut text_start = 0;
351 let mut text_end = text.len();
352 let mut fwd_end = 0;
353
354 for (pos, ch) in text.char_indices().rev() {
355 let end_found = match ch {
356 '(' => {
357 if in_parentheses {
358 in_parentheses = false;
359 if fwd_end - pos > 2
360 && with_lowercase(
361 text.get(pos + 1..fwd_end).unwrap_or_default(),
362 is_fwd_prefix,
363 )
364 {
365 text_end = pos;
366 trim_end = true;
367 continue;
368 }
369 }
370 true
371 }
372 ')' => {
373 if in_parentheses {
374 true
375 } else {
376 in_parentheses = true;
377 fwd_end = pos;
378 false
379 }
380 }
381 _ if ch.is_whitespace() => {
382 if trim_end {
383 text_end = pos;
384 }
385 continue;
386 }
387 _ => !in_parentheses,
388 };
389
390 if end_found {
391 let start = text
392 .char_indices()
393 .find(|(_, ch)| !ch.is_whitespace())
394 .map_or(pos, |(start, _)| start);
395 return text.get(start..text_end).unwrap_or_default();
396 }
397
398 trim_end = false;
399 text_start = pos;
400 }
401
402 text.get(text_start..text_end).unwrap_or_default()
403}
404
405#[cfg(test)]
406mod tests {
407 use super::{thread_name, trim_trailing_fwd};
408 use crate::fields::tests::load_text_pairs;
409
410 #[test]
411 fn thread_name_fixtures() {
412 let tests = load_text_pairs("thread_name.json");
413 assert_eq!(tests.len(), 180);
414 for (input, expected) in tests {
415 assert_eq!(thread_name(&input), expected, "{input:?}");
416 }
417 }
418
419 #[test]
420 fn trailing_fwd_fixtures() {
421 let tests = load_text_pairs("trailing_fwd.json");
422 assert_eq!(tests.len(), 18);
423 for (input, expected) in tests {
424 assert_eq!(trim_trailing_fwd(&input), expected, "{input:?}");
425 }
426 }
427}