1use crate::words::SpamWords;
66use regex::{Captures, Regex};
67use std::collections::HashSet;
68use std::sync::{Arc, LazyLock, Mutex};
69
70pub const TOKENIZER: u32 = 2;
72
73#[derive(Debug, Clone, Default, PartialEq, Eq)]
75pub struct Tokens {
76 pub words: Vec<String>,
80 pub links: Vec<String>,
83}
84
85pub struct Lexicon {
88 pub words: SpamWords,
90 provider_tag: Regex,
91 passes: Vec<Pass>,
92 elisions: HashSet<String>,
93 number_words: HashSet<String>,
94 stop_words: HashSet<String>,
95}
96
97impl std::fmt::Debug for Lexicon {
98 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 f.debug_struct("Lexicon").field("words", &self.words).finish_non_exhaustive()
100 }
101}
102
103fn normalized(list: &[String]) -> Vec<String> {
106 list.iter().map(|w| normalize(w).trim().to_string()).filter(|w| !w.is_empty()).collect()
107}
108
109fn alternatives(words: &[String]) -> String {
112 let escaped: Vec<String> = words.iter().map(|w| regex::escape(w)).collect();
113 let escaped: Vec<&str> = escaped.iter().map(String::as_str).collect();
114 if escaped.is_empty() { r"[^\s\S]".to_string() } else { longest_first(&escaped) }
115}
116
117impl Lexicon {
118 pub fn new(words: &SpamWords) -> Lexicon {
120 let mut marks: Vec<String> = Vec::new();
123 for mark in words.provider_tags.iter().map(|w| w.trim().to_lowercase()).filter(|w| !w.is_empty()) {
124 let bare = crate::words::folded(&mark);
125 marks.push(mark);
126 marks.push(bare);
127 }
128 let months = alternatives(&normalized(&words.months));
129 let keys = format!("{}|[a-z]", alternatives(&normalized(&words.keys)));
131 Lexicon {
132 words: words.clone(),
133 provider_tag: provider_tag(&alternatives(&marks)),
134 passes: passes(&months, &keys),
135 elisions: normalized(&words.elisions).into_iter().collect(),
136 number_words: normalized(&words.number_words).into_iter().collect(),
137 stop_words: normalized(&words.stop_words).into_iter().collect(),
138 }
139 }
140
141 pub fn builtin() -> Arc<Lexicon> {
144 static BUILTIN: LazyLock<Arc<Lexicon>> = LazyLock::new(|| Arc::new(Lexicon::new(&crate::words::Words::builtin_ref().spam)));
145 Arc::clone(&BUILTIN)
146 }
147
148 pub fn cached(words: &SpamWords) -> Arc<Lexicon> {
151 type Cache = Mutex<Vec<(SpamWords, Arc<Lexicon>)>>;
152 static CACHE: LazyLock<Cache> = LazyLock::new(|| Mutex::new(Vec::new()));
153 let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
154 if let Some((_, found)) = cache.iter().find(|(w, _)| w == words) {
155 return Arc::clone(found);
156 }
157 let made = Arc::new(Lexicon::new(words));
158 cache.insert(0, (words.clone(), Arc::clone(&made)));
159 cache.truncate(4);
160 made
161 }
162
163 pub fn without_provider_tags<'a>(&self, subject: &'a str) -> &'a str {
165 let mut rest = subject;
166 while let Some(found) = self.provider_tag.find(rest).filter(|m| m.end() > 0) {
167 rest = &rest[found.end()..];
168 }
169 rest
170 }
171}
172
173pub fn tokens(subject: &str, body: &str) -> Tokens {
176 tokens_with(&Lexicon::builtin(), subject, body)
177}
178
179pub fn tokens_with(lexicon: &Lexicon, subject: &str, body: &str) -> Tokens {
182 let subject = lexicon.without_provider_tags(subject);
183 let text = normalize(&format!("{subject}\n\n{body}"));
184 let text = clean(&text);
185 let mut links = Vec::new();
186 let text = placeholders(lexicon, &text, &mut links);
187 let words = split(lexicon, &text).into_iter().filter_map(|w| word(lexicon, w)).collect();
188 Tokens { words, links }
189}
190
191fn provider_tag(marks: &str) -> Regex {
198 let word = format!("(?:{marks})");
199 Regex::new(&format!(r"(?i)^\s*(?:\*{{2,}}[^*]{{0,40}}?{word}[^*]{{0,40}}?\*{{2,}}|\[[^\]]{{0,40}}?{word}[^\]]{{0,40}}?\]|\{{[^}}]{{0,40}}?{word}[^}}]{{0,40}}?\}}|\([^)]{{0,40}}?{word}[^)]{{0,40}}?\)|{word}\s*:)\s*"))
200 .expect("a valid pattern")
201}
202
203pub fn without_provider_tags(subject: &str) -> &str {
206 Lexicon::builtin().without_provider_tags(subject)
207}
208
209pub fn normalize(text: &str) -> String {
213 let visible: String = text.chars().filter(|c| !invisible(*c)).collect();
214 let composed = icu_normalizer::ComposingNormalizerBorrowed::new_nfkc().normalize(&visible);
215 let folded = fold_lookalikes(&composed);
216 let mut out = String::with_capacity(folded.len());
217 for c in folded.chars().flat_map(char::to_lowercase) {
218 ascii(c, &mut out);
219 }
220 out.split_ascii_whitespace().collect::<Vec<_>>().join(" ")
221}
222
223fn invisible(c: char) -> bool {
228 matches!(c,
229 '\u{00ad}' | '\u{034f}' | '\u{061c}' | '\u{115f}' | '\u{1160}' | '\u{17b4}' | '\u{17b5}' | '\u{180b}'..='\u{180e}'
230 | '\u{200b}'..='\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2060}'..='\u{2064}' | '\u{2066}'..='\u{2069}'
231 | '\u{3164}' | '\u{fe00}'..='\u{fe0f}' | '\u{feff}' | '\u{ffa0}' | '\u{e0000}'..='\u{e007f}' | '\u{e0100}'..='\u{e01ef}')
232}
233
234fn lookalike(c: char) -> Option<char> {
237 Some(match c {
238 'А' | 'Α' => 'A', 'а' | 'α' => 'a',
239 'В' | 'Β' => 'B', 'в' => 'b',
240 'С' | 'Ϲ' => 'C', 'с' | 'ϲ' => 'c',
241 'ԁ' => 'd', 'Ԁ' => 'D',
242 'Е' | 'Ё' | 'Ε' => 'E', 'е' | 'ё' | 'ε' => 'e',
243 'Н' | 'Η' | 'Һ' => 'H', 'һ' | 'н' | 'հ' => 'h',
244 'І' | 'Ї' | 'Ι' | 'Ӏ' => 'I', 'і' | 'ї' | 'ι' => 'i',
245 'Ј' => 'J', 'ј' => 'j',
246 'К' | 'Κ' => 'K', 'к' | 'κ' => 'k',
247 'ӏ' => 'l',
248 'М' | 'Μ' => 'M', 'м' => 'm',
249 'Ν' => 'N', 'ո' => 'n',
250 'О' | 'Ο' => 'O', 'о' | 'ο' | 'օ' => 'o',
251 'Р' | 'Ρ' => 'P', 'р' | 'ρ' => 'p',
252 'Ԛ' => 'Q', 'ԛ' => 'q',
253 'Ѕ' => 'S', 'ѕ' => 's',
254 'Т' | 'Τ' => 'T', 'т' | 'τ' => 't',
255 'υ' | 'ս' => 'u',
256 'ν' => 'v',
257 'Ԝ' => 'W', 'ԝ' | 'ω' => 'w',
258 'Х' | 'Χ' => 'X', 'х' | 'χ' => 'x',
259 'У' | 'Υ' => 'Y', 'у' => 'y',
260 'Ζ' => 'Z',
261 _ => return None,
262 })
263}
264
265fn fold_lookalikes(text: &str) -> String {
269 let mut out = String::with_capacity(text.len());
270 let mut word: Vec<char> = Vec::new();
271 let flush = |word: &mut Vec<char>, out: &mut String| {
272 let latin = word.iter().any(char::is_ascii_alphabetic);
273 let all = word.iter().all(|c| c.is_ascii() || lookalike(*c).is_some());
274 let fold = (latin || all) && word.iter().any(|c| lookalike(*c).is_some());
275 out.extend(word.drain(..).map(|c| if fold { lookalike(c).unwrap_or(c) } else { c }));
276 };
277 for c in text.chars() {
278 if c.is_alphanumeric() {
279 word.push(c);
280 } else {
281 flush(&mut word, &mut out);
282 out.push(c);
283 }
284 }
285 flush(&mut word, &mut out);
286 out
287}
288
289fn ascii(c: char, out: &mut String) {
293 let text: &str = match c {
294 '\u{2019}' | '`' | '\u{2018}' | '\u{2bc}' | '\u{b4}' | '\u{2032}' => "'",
296 '\u{2033}' => "''",
297 '\u{f8b9}' | '\u{f81f}' => " ",
299 '\u{2010}'..='\u{2015}' | '\u{ff0d}' | '\u{fe63}' => "-",
301 '↑' | '↵' | '☙' | '❧' | '🔗' | '•' | '©' | '®' | '|' | '¦' | '™' | '·' | '✔' => " ",
303 '×' => "x",
304 'ƒ' => "f",
305 'ij' => "ij",
307 'œ' | '\u{a7f9}' => "oe",
308 'æ' => "ae",
309 'ß' => "ss",
310 'ø' => "o",
311 'đ' | 'ð' => "d",
312 'ł' => "l",
313 'ı' => "i",
314 'þ' => "th",
315 '\u{fb00}' => "ff",
316 '\u{fb01}' => "fi",
317 '\u{fb02}' => "fl",
318 '\u{fb03}' => "ffi",
319 '\u{fb04}' => "ffl",
320 '\u{fb05}' | '\u{fb06}' => "st",
321 '\u{2026}' => "...",
322 '«' | '»' | '“' | '”' | '„' => "\"",
324 '\u{2044}' => "/",
327 '←' | '⇐' => "<-",
329 '→' | '⇒' => "->",
330 '↔' | '⇔' => "<->",
331 '€' => "eur",
334 '£' => "gbp",
335 '°' => "deg",
336 '⌘' => "cmd",
337 c if c.is_ascii() => {
338 out.push(c);
339 return;
340 }
341 c if c.is_whitespace() => " ",
342 c => {
343 if let Some(base) = unaccented(c) {
344 out.push(base);
345 }
346 return;
347 }
348 };
349 out.push_str(text);
350}
351
352fn unaccented(c: char) -> Option<char> {
357 let mut buffer = [0; 4];
358 let decomposed = icu_normalizer::DecomposingNormalizerBorrowed::new_nfd().normalize(c.encode_utf8(&mut buffer));
359 let mut chars = decomposed.chars();
360 let base = chars.next().filter(char::is_ascii_alphabetic)?;
361 chars.all(|m| ('\u{300}'..='\u{36f}').contains(&m)).then_some(base)
362}
363
364static DOTS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\.{2,}").expect("dots"));
367static DASHES_RUN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"-{2,}").expect("dashes"));
369static QUESTIONS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\?{2,}").expect("questions"));
370static BB_IMG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"\[img[a-zA-Z0-9 ="]*?\].*?\[/img\]"#).expect("bb img"));
371static BB_QUOTE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"\[quote[a-zA-Z0-9 ="]*?\].*?\[/quote\]"#).expect("bb quote"));
372static MARKUP: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[\[\{<]([^\n\r]+?)[\]\}>]").expect("markup"));
373static BASE_64: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?:[A-Za-z0-9\+/]{4}){64,}(?:[A-Za-z0-9\+/]{2}==|[A-Za-z0-9\+/]{3}=)?").expect("base64"));
374
375fn clean(text: &str) -> String {
379 let text = DOTS.replace_all(text, "...");
380 let text = DASHES_RUN.replace_all(&text, "-");
381 let text = QUESTIONS.replace_all(&text, "?");
382 let text = repeated(&text);
383 let text = BB_IMG.replace_all(&text, " ");
384 let text = BB_QUOTE.replace_all(&text, " ");
385 let text = MARKUP.replace_all(&text, " ${1} ");
386 let text = BASE_64.replace_all(&text, " ");
387 text.into_owned()
388}
389
390fn repeated(text: &str) -> String {
393 let chars: Vec<char> = text.chars().collect();
394 let mut out = String::with_capacity(text.len());
395 let mut i = 0;
396 while i < chars.len() {
397 let run = chars[i..].iter().take_while(|c| **c == chars[i]).count();
398 if run >= 10 {
399 out.push(' ');
400 } else {
401 out.extend(&chars[i..i + run]);
402 }
403 i += run;
404 }
405 out
406}
407
408const BEFORE: &str = r#"(?:^|[\s\[\(\{<'"`;>])"#;
413const AFTER: &str = r#"(?:$|[\s\]\)\}>'"`;<])"#;
415const END: &str = r#"(?:$|[\s\]\)\}>'"`;:,\?!\.<])"#;
417const SIGN: &str = r"[\+\-=]";
419const SIGN_SPACE: &str = r"[\+\-=]? ?";
421const SIGN_ONLY: &str = r"[\+\-=]?";
422const AMOUNT: &str = r"[0-9]+(?:[\.,\-\+/ ][0-9]*)*?";
424const NAME: &str = r#"[^#%<>&\*\{\}\\/\?\$!\|="'@\s\x08]"#;
426const NOT_NAME: &str = r#"(?:^|[#%<>&\*\{\}\\/\?\$!\|="'@\s\x08])"#;
427const FILE_END: &str = r"(?:$|\s|\S$|\S\s)";
430const PREFIXES: &str = "nano|n|micro|milli|m|centi|c|deci|d|deca|hecto|kilo|k|mega|giga|g";
431
432struct Pass {
435 re: Regex,
436 with: &'static str,
437 kind: Kind,
438}
439
440#[derive(Clone, Copy, PartialEq, Eq)]
441enum Kind {
442 Plain,
443 Url,
445 Iban,
447 Phone,
449}
450
451impl Pass {
452 fn new(pattern: &str, with: &'static str) -> Pass {
453 Pass::of(pattern, with, Kind::Plain)
454 }
455
456 fn of(pattern: &str, with: &'static str, kind: Kind) -> Pass {
457 let re = Regex::new(pattern).unwrap_or_else(|e| panic!("{with}: {e}"));
458 Pass { re, with, kind }
459 }
460
461 fn apply(&self, text: &str, links: &mut Vec<String>) -> String {
462 let mut out = String::with_capacity(text.len());
463 let (mut last, mut at) = (0, 0);
464 while at <= text.len() {
465 let Some(caps) = self.re.captures_at(text, at) else { break };
466 let Some(m) = caps.name("m") else { break };
467 at = if m.end() > at { m.end() } else { at + 1 };
468 if !self.keeps(&caps, m.as_str(), links) {
469 continue;
470 }
471 out.push_str(&text[last..m.start()]);
472 out.push_str(self.with);
473 last = m.end();
474 }
475 out.push_str(&text[last..]);
476 out
477 }
478
479 fn keeps(&self, caps: &Captures, matched: &str, links: &mut Vec<String>) -> bool {
481 match self.kind {
482 Kind::Plain => true,
483 Kind::Url => {
484 if let Some(host) = caps.name("host").or_else(|| caps.name("www")) {
485 links.push(registrable(host.as_str()));
486 }
487 true
488 }
489 Kind::Iban => iban_valid(matched),
490 Kind::Phone => (8..=15).contains(&matched.chars().filter(char::is_ascii_digit).count()),
491 }
492 }
493}
494
495fn longest_first(words: &[&str]) -> String {
497 let mut sorted: Vec<&str> = words.to_vec();
498 sorted.sort_by(|a, b| b.len().cmp(&a.len()).then(a.cmp(b)));
499 sorted.dedup();
500 sorted.join("|")
501}
502
503fn file(extensions: &[&str], with: &'static str) -> Pass {
504 Pass::new(&format!(r"(?i){NOT_NAME}(?P<m>{NAME}+\.(?:{})){FILE_END}", longest_first(extensions)), with)
505}
506
507fn unit(head: &str, unit: &str, with: &'static str) -> Pass {
510 Pass::new(&format!(r"(?i){BEFORE}(?P<m>{head}{AMOUNT} ?{unit}){END}"), with)
511}
512
513fn passes(months: &str, keys: &str) -> Vec<Pass> {
516 let link = r"[A-Za-z0-9\-_\.~:/\[\]@!\$&'\(\)\*\+,;=%]";
517 let host = r"[A-Za-z0-9\-_\.~]+";
518 vec![
519 Pass::new(r"(?P<m>[0-9A-Za-z_\-\+\.]*@[0-9A-Za-z_\-\+\.]+|user\-?[0-9]+)", " _USER_ "),
521 Pass::of(&format!(r"(?i){BEFORE}(?P<m>(?:(?:(?:http|ftp)s?)?:?//(?P<host>{host})|(?P<www>www\.{host}))(?::[0-9]*)?/?{link}*(?:\?{link}*)?(?:\#{link}*)?){END}"), " _URL_ ", Kind::Url),
523 Pass::new(&format!(r"(?i){BEFORE}(?P<m>(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){{3}}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|(?:fe80::)?(?:[0-9a-f]{{1,4}}:){{3,8}}(?::?/?[0-9a-f]{{1,4}})*){AFTER}"), " _IP_ "),
524 Pass::of(&format!(r"(?i){BEFORE}(?P<m>[a-z]{{2}}[0-9]{{2}}(?: ?[a-z0-9]{{4}}){{2,7}}(?: ?[a-z0-9]{{1,3}})?){END}"), " _IBAN_ ", Kind::Iban),
526 Pass::of(&format!(r"{BEFORE}(?P<m>(?:\+|00)[1-9][0-9]{{0,2}}(?:[ \.\-]?\(?[0-9]{{1,4}}\)?){{2,6}}|0[1-9](?:[ \.\-]?[0-9]{{2}}){{4}}|\([0-9]{{3}}\) ?[0-9]{{3}}[ \.\-]?[0-9]{{4}}){END}"), " _PHONE_ ", Kind::Phone),
527 file(&["php", "m", "py", "sh", "c", "cxx", "cpp", "h", "hxx", "a", "asm", "awk", "asp", "class", "java", "yml", "yaml", "js", "css", "cl"], " _CODEFILE_ "),
529 file(&["db", "sql", "sqlite"], " _DATABASEFILE_ "),
530 file(&["bmp", "jpg", "jpeg", "jpe", "jp2", "j2c", "j2k", "jpc", "jpf", "jpx", "png", "ico", "svg", "webp", "heif", "heic", "tif", "tiff", "hdr", "exr", "ppm", "pfm", "nef", "rw2", "cr2", "cr3", "crw", "dng", "raf", "arw", "srf", "sr2", "iiq", "3fr", "dcr", "ari", "pef", "x3f", "erf", "raw", "rwz", "orf"], " _IMAGEFILE_ "),
531 file(&["xfc", "kra", "psd", "ai", "indd", "ps", "eps", "pdf", "xlsx", "docx", "pptx", "doc", "xls", "ppt", "odt", "ods", "odp", "odg", "odf", "wpd"], " _DOCUMENTFILE_ "),
532 file(&["txt", "md", "html", "xml", "xhtml", "xmp", "json", "tex", "rst", "rtf"], " _TEXTFILE_ "),
533 file(&["zip", "gzip", "gz", "tar", "bz", "iso", "rar", "img"], " _ARCHIVEFILE_ "),
534 Pass::new(&format!(r"(?i){NOT_NAME}(?P<m>{NAME}+\.(?:{})(?:\.[a-z0-9]+)*)(?:$|[^a-zA-Z])", longest_first(&["so", "exe", "dmg", "appimage", "bin", "run", "apk", "jar", "cmd", "workflow", "action", "autorun", "osx", "app", "vb", "dll", "scr", "rpm", "deb", "distinfo"])), " _BINARYFILE_ "),
535 Pass::new(&format!(r"(?i)(?P<m>(?:[0-9]{{1,2}})? (?:{months})\.?(?: [0-9]{{1,2}})?(?: [0-9]{{2,4}}))(?:$|[^:])"), " _DATE_ "),
537 Pass::new(r#"(?:^|[\s\[\(\{<'"`;])(?P<m>[0-9]{1,4}[\-/][0-9]{2}[\-/][0-9]{2,4})(?:$|[\s\]\)\}>'"`;:,\?!\.t])"#, " _DATE_ "),
538 Pass::new(r#"(?i)(?:^|[\s\[\(\{<'"`;t])(?P<m>[0-9]{1,2} ?(?:h|:|am|pm) ?(?:[0-9]{2})?(?::[0-9]{2})? ?(?:h|am|pm|z|utc)? ?(?:[\+\-][0-9]{1,4})?)(?:$|[\s\]\)\}>'"`;:,\?!\.])"#, " _TIME_ "),
539 Pass::new(&format!(r"(?i){BEFORE}(?P<m>(?:fn|tab|ctrl|shift|alt|altgr|maj|command|cmd|option|menu)(?: ?\+ ?(?:{keys}))+){END}"), " _SHORTCUT_ "),
541 Pass::new(&format!(r"{BEFORE}(?P<m>(?:[a-z]:\\{{1,2}}|\./|~/|/)(?:{NAME}+(?:\\|/)?)+){END}"), " _PATH_ "),
544 Pass::new(&format!(r#"(?:^|[\s\['\("])(?P<m>{SIGN}?(?:[0-9]+[\.,/\+\-\s]*){{5,}})(?:$|[\s:;,\?!\]\)'"])"#), "123456789"),
546 unit(SIGN_SPACE, "(?:ev|il)s?", " _EXPOSURE_ "),
548 Pass::new(&format!(r"(?i){BEFORE}(?P<m>1/[0-9]+ ?(?:th|s|sec)){END}"), " _SHUTTERSPEED_ "),
549 Pass::new(&format!(r"(?i){BEFORE}(?P<m>(?:iso|asa) ?{AMOUNT}|{AMOUNT} ?(?:iso|asa)s?){END}"), " _SENSIBILITY_ "),
552 unit(SIGN_SPACE, r"(?:cd/m2|cd/m\^2|nit|nits)", " _LUMINANCE_ "),
553 unit(SIGN_SPACE, "(?:kilo|k|mega|m|giga|g|tera|t|peta|p)?i?(?:b|o|bit|byte)s?", " _FILESIZE_ "),
554 unit(SIGN_SPACE, &format!("(?:{PREFIXES})?(?:m|meter|metre|in|inch|inche|ft|foot|feet|'|'')s?"), " _DISTANCE_ "),
555 unit(SIGN_SPACE, &format!("(?:{PREFIXES})?(?:g|gram|gramme|lb|pound)s?"), " _WEIGHT_ "),
556 unit(SIGN_ONLY, "(?:degc|degree c|celsius|k|degf|kelvin)", " _TEMPERATURE_ "),
557 unit(SIGN_SPACE, "(?:deg|degree|degre|rad|radian|sr|steradian)s?", " _ANGLE_ "),
558 unit(SIGN_ONLY, &format!("(?:{PREFIXES})?(?:hz|hertz)"), " _FREQUENCY_ "),
559 unit(SIGN_SPACE, "%", " _PERCENT_ "),
560 unit(SIGN_SPACE, "(?:db|decibel)s?", " _GAIN_ "),
561 Pass::new(&format!(r"(?i){BEFORE}(?P<m>f/?[0-9]+\.?[0-9]?)"), " _APERTURE_ "),
562 Pass::new(&format!(r"(?i){BEFORE}(?P<m>{SIGN}? ?[0-9]+ ?(?:kilo|k|mega|m|giga|g|tera|t|peta|p)?(?:p|px|pixels|pix)s?){END}"), " _PIXELS_ "),
563 Pass::new(&format!(r"(?i){BEFORE}(?P<m>[0-9]+(?:st|nd|rd|th|e|er|ere|nde|eme)){END}"), " _ORDINAL_ "),
565 Pass::new(r"(?P<m>ndeg ?[0-9]+)", " _ORDINAL_ "),
566 Pass::new(&format!(r"(?i){BEFORE}(?P<m>{SIGN}?(?:k?(?:usd|eur|gbp|\$) ?(?:[0-9]+[\.,\+\- ]?)+|(?:[0-9]+[\.,\+\- ]?)+ ?k?(?:usd|eur|gbp|\$))){END}"), " _PRICE_ "),
568 Pass::new(r"(?P<m>[0-9]+x[0-9]+)", " _RESOLUTION_ "),
569 Pass::new(&format!(r"{BEFORE}(?P<m>(?:{NAME}+(?:\\|/)){{2,}}(?:{NAME}+)?){END}"), " _PATH_ "),
571 Pass::new(r"[0-9A-Za-z_](?P<m>[\-_=]+)[0-9A-Za-z_]", "_"),
573 ]
574}
575
576fn placeholders(lexicon: &Lexicon, text: &str, links: &mut Vec<String>) -> String {
579 let mut text = text.to_string();
580 for pass in &lexicon.passes {
581 text = pass.apply(&text, links);
582 }
583 text.replace(':', " ").replace('\\', "")
584}
585
586pub fn registrable(host: &str) -> String {
590 let host = host.trim_end_matches('.').to_ascii_lowercase();
591 let host = host.strip_prefix("www.").unwrap_or(&host);
592 if host.parse::<std::net::IpAddr>().is_ok() {
593 return host.to_string();
594 }
595 let labels: Vec<&str> = host.split('.').filter(|l| !l.is_empty()).collect();
596 let second_level = ["co", "com", "net", "org", "gov", "gouv", "ac", "edu", "ne", "or", "go", "mil", "nom", "ltd", "plc", "sch", "asso"];
597 let keep = match labels.as_slice() {
598 [.., second, top] if top.len() == 2 && second_level.contains(second) => 3,
599 _ => 2,
600 };
601 labels[labels.len().saturating_sub(keep)..].join(".")
602}
603
604fn iban_valid(text: &str) -> bool {
607 let compact: Vec<char> = text.chars().filter(|c| !c.is_whitespace()).collect();
608 if !(15..=34).contains(&compact.len()) {
609 return false;
610 }
611 let mut rest: u32 = 0;
612 for c in compact[4..].iter().chain(&compact[..4]) {
613 let Some(value) = c.to_digit(36) else { return false };
614 rest = if value < 10 { (rest * 10 + value) % 97 } else { (rest * 100 + value) % 97 };
615 }
616 rest == 1
617}
618
619fn word_byte(b: u8) -> bool {
622 b.is_ascii_alphanumeric() || b == b'_'
623}
624
625fn split<'a>(lexicon: &Lexicon, text: &'a str) -> Vec<&'a str> {
630 let bytes = text.as_bytes();
631 let mut words = Vec::new();
632 let mut start: Option<usize> = None;
633 for i in 0..=bytes.len() {
634 let joined = |i: usize| {
635 i > 0 && i + 1 < bytes.len() && {
636 let (before, here, after) = (bytes[i - 1], bytes[i], bytes[i + 1]);
637 match here {
638 b'.' | b',' => word_byte(before) && word_byte(after),
639 b'\'' => before.is_ascii_alphabetic() && after.is_ascii_alphabetic(),
640 _ => false,
641 }
642 }
643 };
644 let inside = i < bytes.len() && (word_byte(bytes[i]) || (start.is_some() && joined(i)));
645 match (inside, start) {
646 (true, None) => start = Some(i),
647 (false, Some(s)) => {
648 words.extend(elided(lexicon, &text[s..i]));
649 start = None;
650 }
651 _ => {}
652 }
653 }
654 words
655}
656
657fn elided<'a>(lexicon: &Lexicon, mut word: &'a str) -> Option<&'a str> {
661 while let Some((head, rest)) = word.split_once('\'') {
662 let vowel = rest.bytes().next().is_some_and(|b| b"aeiouyh".contains(&b));
663 if !(vowel && lexicon.elisions.contains(head)) {
664 break;
665 }
666 word = rest;
667 }
668 (!word.is_empty()).then_some(word)
669}
670
671pub const PLACEHOLDERS: &[&str] = &[
675 "_USER_", "_URL_", "_IP_", "_IBAN_", "_PHONE_", "_CODEFILE_", "_DATABASEFILE_", "_IMAGEFILE_", "_DOCUMENTFILE_", "_TEXTFILE_",
676 "_ARCHIVEFILE_", "_BINARYFILE_", "_DATE_", "_TIME_", "_SHORTCUT_", "_PATH_", "_EXPOSURE_", "_SHUTTERSPEED_", "_SENSIBILITY_",
677 "_LUMINANCE_", "_FILESIZE_", "_DISTANCE_", "_WEIGHT_", "_TEMPERATURE_", "_ANGLE_", "_FREQUENCY_", "_PERCENT_", "_GAIN_",
678 "_APERTURE_", "_PIXELS_", "_ORDINAL_", "_PRICE_", "_RESOLUTION_", "_NUMBER_", "_HASH_",
679];
680
681fn hash(word: &str) -> bool {
690 let digits = word.bytes().filter(u8::is_ascii_digit).count();
691 (digits == word.len() && digits >= 5) || (word.len() >= 6 && digits > 0 && word.bytes().all(|b| b.is_ascii_hexdigit()))
692}
693
694static NUMBER: LazyLock<Regex> = LazyLock::new(|| {
695 Regex::new(r"^[\+\-=]?(?:0x[0-9a-f]+u?|[0-9]+(?:[\.,][0-9]+)*e[\+\-]?[0-9]+[fu]?|[0-9]+(?:[\.,][0-9]+)*[fu]?)$").expect("number")
697});
698
699fn word(lexicon: &Lexicon, token: &str) -> Option<String> {
701 let token = token.trim_matches(|c: char| "?!#=+-,:;'\"^*./`()[]{}& \n\r\t<>".contains(c));
702 if token.is_empty() {
703 return None;
704 }
705 if PLACEHOLDERS.contains(&token) {
706 return Some(token.to_string());
707 }
708 if lexicon.number_words.contains(token) {
709 return Some("_NUMBER_".into());
710 }
711 let compound = token.len() > 2 && token[1..token.len() - 1].contains('_');
714 let token = if compound { token } else { token.trim_matches('_') };
715 if !compound && hash(token) {
716 return Some("_HASH_".into());
717 }
718 if !compound && NUMBER.is_match(token) {
719 return Some("_NUMBER_".into());
720 }
721 if token.len() < 2 || lexicon.stop_words.contains(token) {
722 return None;
723 }
724 let joined = token.replace('_', "");
725 let stemmed = stem(&joined);
726 (!stemmed.is_empty()).then_some(stemmed)
727}
728
729pub fn stem(word: &str) -> String {
734 let mut out = String::with_capacity(word.len());
735 let mut run = String::new();
736 for c in word.chars() {
737 if c.is_ascii_alphanumeric() || c == '_' {
738 run.push(c);
739 } else {
740 out.push_str(&stem_run(&run));
741 run.clear();
742 out.push(c);
743 }
744 }
745 out.push_str(&stem_run(&run));
746 out
747}
748
749fn stem_run(run: &str) -> String {
750 if run.is_empty() {
751 return String::new();
752 }
753 let mut w = single_consonants(run);
754 let rules: &[(usize, &[&str], &str)] = &[
757 (4, &["sees", "ees", "ses", "es", "ss", "s"], ""),
759 (3, &["our"], "or"),
761 (4, &["ity", "ite"], ""),
763 (4, &["ee", "e"], ""),
765 (4, &["trice", "teur", "tor"], "t"),
767 (4, &["ed"], ""),
769 (4, &["ement", "ment"], ""),
771 (4, &["tion", "sion"], "*ion"),
773 (3, &["ist", "ism"], ""),
775 (4, &["at"], ""),
777 (2, &["tif", "tiv"], "t"),
779 (3, &["y"], "i"),
781 (5, &["er"], ""),
783 (4, &["iz", "yz"], "*s"),
785 (3, &["eur"], "or"),
787 (3, &["iqu", "ic"], "i"),
789 ];
790 for (index, (least, suffixes, with)) in rules.iter().enumerate() {
791 let fits = |suffix: &&&str| {
794 let at = w.len().wrapping_sub(suffix.len());
795 w.len() >= suffix.len() + least && w.ends_with(**suffix) && (index != 0 || w.as_bytes()[at - least..at].iter().all(u8::is_ascii_alphabetic))
796 };
797 let Some(suffix) = suffixes.iter().filter(fits).max_by_key(|s| s.len()) else { continue };
798 let at = w.len() - suffix.len();
799 let replacement = match *with {
800 "*ion" => suffix[..1].to_string(),
802 "*s" => format!("{}s", &suffix[..1]),
803 other => other.to_string(),
804 };
805 w.truncate(at);
806 w.push_str(&replacement);
807 }
808 w
809}
810
811fn single_consonants(run: &str) -> String {
813 let bytes = run.as_bytes();
814 let mut out = String::with_capacity(run.len());
815 let mut i = 0;
816 while i < bytes.len() {
817 out.push(bytes[i] as char);
818 if i >= 2 && i + 1 < bytes.len() && bytes[i] == bytes[i + 1] && b"bcfghjklmnpqrstvwxz".contains(&bytes[i]) {
819 i += 2;
820 } else {
821 i += 1;
822 }
823 }
824 out
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830
831 fn placed(text: &str) -> Vec<String> {
833 let mut links = Vec::new();
834 placeholders(&Lexicon::builtin(), &clean(&normalize(text)), &mut links).split_whitespace().filter(|w| w.starts_with('_') && w.ends_with('_') && w.len() > 2).map(str::to_string).collect()
835 }
836
837 fn words(text: &str) -> Vec<String> {
838 tokens("", text).words
839 }
840
841 #[test]
843 fn providers_marks_are_taken_off() {
844 for (subject, rest) in [
845 ("***Potentiel-SPAM*** Votre colis", "Votre colis"),
846 ("***Possible-SPAM***Votre colis", "Votre colis"),
847 ("**SPAM** [SPAM?] {Spam} Offer", "Offer"),
848 ("[POURRIEL] (Indésirable) SPAM: hello", "hello"),
849 (" [Junk mail] hello", "hello"),
850 ("[Ham] hello", "[Ham] hello"),
851 ("Re: spam filters [SPAM]", "Re: spam filters [SPAM]"),
852 ("Spammers again", "Spammers again"),
853 ("", ""),
854 ] {
855 assert_eq!(without_provider_tags(subject), rest, "{subject}");
856 }
857 assert_eq!(tokens("***Potentiel-SPAM*** Facture", "").words, tokens("Facture", "").words);
858 assert!(tokens("Spam filters", "").words.len() == 2, "a subject about spam keeps its words");
859 }
860
861 #[test]
862 fn times_as_virtual_secretary_caught_them() {
863 for time in ["12h15", "12:15", "12:15:00", "12am", "12 am", "12 h", "12:15:00Z", "12:15:00+01", "12:15:00 UTC+1", "6:45 am", "6:45am", "6 am", "6am", "24 h", "12 h 12", "12:45 UTC+2", "12:45Z", "14:45+01", "14:45:00-02", "15H"] {
865 assert_eq!(placed(&format!("- {time}")), ["_TIME_"], "{time}");
866 }
867 assert_eq!(placed("Added:\n2023-03-21T11:27:45+0000"), ["_DATE_", "_TIME_"]);
868 for other in ["1", "23", "2022-12-01", "1 €"] {
870 assert!(!placed(&format!("- {other}")).contains(&"_TIME_".to_string()), "{other}");
871 }
872 assert_eq!(placed("- 45 $"), ["_PRICE_"]);
873 }
874
875 #[test]
876 fn links_and_their_domains() {
877 let urls = [
879 "https://moi.com", "https://moi.com/", "https://moi.com/page", "http://domain.ext/page/subpage/", "http://domain.ext/page/subpage/#stuff",
880 "http://domain.ext/page/subpage/?q=x&r=0:1+i", "http://domain.ext/page/subpage/?q=x&r=0:1+i#stuff", "http://domain.ext/page/subpage/#stuff?q=x&r=0:1+i",
881 "ftp://ftp.me.com/page", "//meh.photo", "http://localhost:2080",
882 "https://web.archive.org/web/20181103230658im_/http://atelier-malopelli.com/wp-content/uploads/2016/03/Universelle-Sol-Re%CC%81-2-.jpg",
883 ];
884 for url in urls {
885 assert_eq!(placed(url), ["_URL_"], "{url}");
886 assert_eq!(placed(&format!("Stuff {url} blah")), ["_URL_"], "{url}");
887 }
888 assert!(placed("Stuffhttps://moi.com blah").is_empty());
890 for wrapped in ["Blah <a href=\"https://at.dot.com/page\">Blah</a>.", "Blah [link](https://at.dot.com/page/)", "Meh [https://at.dot.com/page]", "<http://domain.ext/page/subpage/?q=x&r=0:1+i#stuff>"] {
891 assert!(placed(wrapped).contains(&"_URL_".to_string()), "{wrapped}");
892 }
893 let t = tokens("Votre colis", "Suivez-le sur https://track.shop.example.co.uk/x?id=1 ou www.Colis-Express.example/suivi.");
894 assert_eq!(t.links, ["example.co.uk", "colis-express.example"]);
895 assert_eq!(t.words.iter().filter(|w| *w == "_URL_").count(), 2);
896 assert_eq!((registrable("mail.news.example.org"), registrable("impots.gouv.fr"), registrable("192.0.2.7")), ("example.org".into(), "impots.gouv.fr".into(), "192.0.2.7".into()));
897 }
898
899 #[test]
900 fn addresses_numbers_and_prices() {
901 for ip in ["192.168.1.1", "172.0.0.0", "79.241.182.32", "2001:0db8:0000:85a3:0000:0000:ac1f:8001", "2001:db8:0:85a3::ac1f:8001", "2001:41D0:1:2E4e::1"] {
902 assert_eq!(placed(ip), ["_IP_"], "{ip}");
903 }
904 for price in ["13€", "13 €", "13.54 €", "€13.54", "€ 13.54", "50.2k€", "1 000 €", "1,000.54 €", "52,52 €", "+50 €", "-200€", "$45", "£12"] {
905 assert_eq!(placed(price), ["_PRICE_"], "{price}");
906 }
907 for percent in ["20%", "25 %", "26.1 %", "27.5%"] {
908 assert_eq!(placed(percent), ["_PERCENT_"], "{percent}");
909 }
910 assert!(placed("21").is_empty());
911 let one = |w: &str| word(&Lexicon::builtin(), w).unwrap_or_default();
913 assert_eq!([one("2045"), one("e62fabc2"), one("2.5"), one("4.999.23")], ["_NUMBER_", "_HASH_", "_NUMBER_", "_NUMBER_"]);
914 assert_eq!(words("2045 e62fabc2 2.5 4.999.23"), ["_NUMBER_", "_HASH_", "_HASH_"]);
916 assert_eq!(words("commande 482913 decade"), ["comand", "_HASH_", "decad"]);
918 assert_eq!(placed("ma.mail+tag@example.org ou @pseudo"), ["_USER_", "_USER_"]);
919 }
920
921 #[test]
922 fn phones_and_bank_accounts() {
923 for phone in ["01 99 00 12 34", "01.99.00.12.34", "0199001234", "+33 1 99 00 12 34", "0033 5 36 49 12 34", "+33 (0)4 65 71 12 34"] {
925 assert_eq!(placed(&format!("Appelez le {phone} vite")), ["_PHONE_"], "{phone}");
926 }
927 assert_eq!(placed("IBAN : FR76 3000 6000 0112 3456 7890 189"), ["_IBAN_"]);
929 assert_eq!(placed("FR7630006000011234567890189"), ["_IBAN_"]);
930 assert!(!placed("FR76 3000 6000 0112 3456 7890 188").contains(&"_IBAN_".to_string()));
931 }
932
933 #[test]
934 fn units_shortcuts_and_paths() {
935 assert_eq!(placed("50mm f/1.8 G"), ["_DISTANCE_", "_APERTURE_"]);
936 assert_eq!(placed("1/800s 1/250 sec 1/1000th"), ["_SHUTTERSPEED_", "_SHUTTERSPEED_", "_SHUTTERSPEED_"]);
937 for shortcut in ["Shift+Click", "Ctrl+Click", "Shift+Maj+E", "tab + t", "ctrl + tab", "cmd + shift + f1", "⌘+C"] {
938 assert_eq!(placed(shortcut), ["_SHORTCUT_"], "{shortcut}");
939 }
940 for path in [r"C:\\windows\stuff\doc", "~/images/stuff/i", "/home/user/stuff", "./subdir/here"] {
941 assert_eq!(placed(path), ["_PATH_"], "{path}");
942 }
943 assert_eq!(placed("f/8"), ["_APERTURE_"]);
945 assert_eq!(placed("https://company.com"), ["_URL_"]);
946 assert_eq!(placed("n° 12; 20°C; 45°; 1080p; 1920x1080; 3e"), ["_ORDINAL_", "_TEMPERATURE_", "_ANGLE_", "_PIXELS_", "_RESOLUTION_", "_ORDINAL_"]);
947 assert_eq!(placed("n° 12, 20°C"), ["_TEMPERATURE_"]);
949 assert_eq!(placed("facture.pdf, photo.tiff et setup.exe"), ["_DOCUMENTFILE_", "_IMAGEFILE_", "_BINARYFILE_"]);
950 assert_eq!(placed("le 12 janvier 2026, le 2026-01-12"), ["_DATE_", "_DATE_"]);
951 }
952
953 #[test]
954 fn french_elisions_and_contractions() {
955 assert_eq!(words("L’information qu’il n’a pas d’autre"), ["inform", "autr"]);
956 assert_eq!(words("aujourd'hui, jusqu'à présent"), ["aujourd'hui", "present"]);
957 assert_eq!(words("Don't wait, it's free"), ["don't", "wait", "free"]);
959 assert_eq!(words("Réponse urgente señor Bestätigung"), ["repons", "urgent", "senor", "bestatigung"]);
961 }
962
963 #[test]
964 fn invisible_letters_and_lookalikes() {
965 assert_eq!(words("V\u{200b}i\u{200c}a\u{200d}g\u{2060}r\u{feff}a"), words("Viagra"));
967 assert_eq!(words("Vіаgrа"), words("Viagra"));
969 assert_eq!(words("PаyPаl"), words("PayPal"));
970 assert_eq!(words("РАУ"), words("PAY"));
971 assert_eq!(words("𝐕𝐢𝐚𝐠𝐫𝐚 FREE"), words("viagra free"));
973 assert_eq!(words("привет"), Vec::<String>::new());
975 assert_eq!(words("½ ™"), ["_NUMBER_", "_NUMBER_", "tm"]);
977 }
978
979 #[test]
980 fn stems_as_virtual_secretary() {
981 let stems: Vec<String> = ["lenses", "colour", "activity", "capitale", "operateur", "managed", "management", "action", "commission", "feminist", "actif", "apply", "optimizer", "serveur", "politique", "process", "tour"].iter().map(|w| stem(w)).collect();
982 assert_eq!(stems, ["lens", "color", "act", "capital", "oper", "manag", "manag", "action", "comis", "femin", "act", "appli", "optimis", "servor", "politi", "proc", "tour"]);
985 }
986
987 #[test]
988 fn output_is_ascii_and_cleaned() {
989 let t = tokens("RE: Offre exclusive !!!!!!!!!!!!", "Bonjour,\n\n« Gagnez 1 000 € » — cliquez ici : https://promo.example/x\n==========\nLa suite… ????? -- aGVsbG8gd29ybGQgaGVsbG8gd29ybGQgaGVsbG8gd29ybGQgaGVsbG8gd29ybGQgaGVsbG8gd29ybGQgaGVsbG8gd29ybGQgaGVsbG8gd29ybGQgaGVsbG8gd29ybGQgaGVsbG8gd29ybGQgaGVsbG8gd29ybGQgaGVsbG8gd29ybGQgaGVsbG8gd29ybGQgaGVsbG8gd29ybGQgaGVsbG8gd29ybGQgaGVsbG8gd29ybGQgaGVsbG8gd29ybGQgaGVsbG8gd29ybGQgaGVsbG8gd29ybGQgaGVsbG8gd29ybGQgaGVsbG8gd29ybGQgaGVsbG8gd29ybGQgaGVsbG8gd29ybGQ=");
990 assert!(t.words.iter().all(|w| w.is_ascii() && !w.contains(' ')), "{:?}", t.words);
991 assert_eq!(t.words, ["offr", "exclusiv", "bonjor", "gagnez", "_PRICE_", "cliquez", "_URL_", "suit"]);
992 assert_eq!(t.links, ["promo.example"]);
993 }
994
995 #[test]
998 fn a_lexicon_reads_with_its_own_words() {
999 let mut words = crate::words::Words::builtin_ref().spam.clone();
1000 words.provider_tags.push("Verdächtig".into());
1001 words.stop_words.push("Rechnung".into());
1002 words.number_words.push("drei".into());
1003 words.months.push("März".into());
1004 words.elisions.retain(|e| e != "l");
1005 let lexicon = Lexicon::new(&words);
1006 let read = |subject: &str, body: &str| tokens_with(&lexicon, subject, body).words;
1007 let builtin = |subject: &str, body: &str| tokens(subject, body).words;
1008 assert_eq!(read("[VERDÄCHTIG] Angebot", ""), read("Angebot", ""));
1009 assert_eq!(read("(verdachtig) Angebot", ""), read("Angebot", ""));
1010 assert_ne!(builtin("[VERDÄCHTIG] Angebot", ""), builtin("Angebot", ""));
1011 assert_eq!(read("", "Rechnung drei"), ["_NUMBER_"]);
1012 assert_eq!(builtin("", "Rechnung drei").len(), 2);
1013 assert!(read("", "am 3 März 2026").contains(&"_DATE_".to_string()));
1014 assert!(!builtin("", "am 3 März 2026").contains(&"_DATE_".to_string()));
1015 assert_ne!(read("", "l'information"), builtin("", "l'information"));
1016 assert_eq!(read("[SPAM] Offre", ""), builtin("Offre", ""), "the built-in marks stay");
1017 let empty = Lexicon::new(&SpamWords::default());
1019 assert_eq!(empty.without_provider_tags("[SPAM] Offre"), "[SPAM] Offre");
1020 let unknown = tokens_with(&empty, "", "the 3 mars 2026, ctrl+x").words;
1021 assert!(unknown.contains(&"the".to_string()) && unknown.contains(&"_SHORTCUT_".to_string()) && !unknown.contains(&"_DATE_".to_string()), "{unknown:?}");
1022 assert!(Arc::ptr_eq(&Lexicon::cached(&words), &Lexicon::cached(&words.clone())));
1024 }
1025}