Skip to main content

sioul_core/spam/
tokenize.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The spam filter's tokenizer, version 2: what a message says, as the words
5//! the language model learns on the desktop and the table scores on every
6//! device.
7//!
8//! A port of Virtual Secretary's text pipeline (its `src/core/nlp.py`:
9//! `Tokenizer.prefilter`, `normalize_token`, `lemmatize`; `patterns.py`;
10//! `language.py`; `utils.py`: `typography_undo`), rewritten for the `regex`
11//! crate, which has no look-around: a look-behind or a look-ahead of one
12//! character becomes a character matched before or after the part replaced,
13//! and the search goes on from the end of that part, so that the character
14//! after one match can stand before the next (`Pass::apply`). Training and
15//! scoring share this one function: any change to a step changes the words,
16//! so it raises `TOKENIZER`, and a table made by another version is refused.
17//!
18//! The steps, in order:
19//! 1. **The text:** the subject, a blank line, the body (HTML mail as its visible text).
20//!    A mark your provider put at the start of the subject (`***Potentiel-SPAM***`,
21//!    `[SPAM]`, `{Spam?}`) is taken off first (`without_provider_tags`): it is
22//!    the provider's verdict, not the sender's words, and a model that read it
23//!    would learn to copy the provider (version 2: a provider may tag nearly
24//!    every message it sets aside).
25//! 2. **Normalization** (`normalize`): invisible characters out (zero-width
26//!    spaces and joiners would otherwise split or hide words); NFKC; letters
27//!    borrowed from other scripts folded into the Latin ones they mimic, in
28//!    words that mix them ("Vіаgrа" with Cyrillic і and а); lowercase (after
29//!    NFKC, so that its capitals, "™" → "TM", "𝐕" → "V", come out lowercase);
30//!    Virtual Secretary's table (apostrophes, spaces, dashes, quotes,
31//!    ligatures, fractions, arrows; accents stripped, every accent, not only
32//!    French ones); the symbols its patterns read written in ASCII
33//!    (€ → "eur", £ → "gbp", ° → "deg", ⌘ → "cmd"); everything else not ASCII
34//!    dropped (Virtual Secretary's choice); whitespace collapsed.
35//! 3. **Cleanup** (`clean`): runs of dots, dashes and question marks made one,
36//!    a character repeated ten times or more, BB code and base64 blocks
37//!    removed, markup brackets dropped (their text kept).
38//! 4. **Placeholders** (`placeholders`): Virtual Secretary's 36 meta-tokens,
39//!    in its order, with `_IBAN_` and `_PHONE_` after the addresses; each link
40//!    also gives its domain (`Tokens::links`).
41//! 5. **Splitting** (`split`): words on whitespace and punctuation, a dot or a
42//!    comma kept between letters or digits ("2.5", "e.g"), an apostrophe
43//!    between letters ("don't", "aujourd'hui"), French elisions split off and
44//!    dropped ("l'information" → "information").
45//! 6. **Per word** (`word`): placeholders kept; number words → `_NUMBER_`; five
46//!    digits or more, or a hexadecimal hash → `_HASH_`; numbers → `_NUMBER_`;
47//!    single characters and Virtual Secretary's stop words out; its French and
48//!    English suffix stemmer.
49//!
50//! The words these steps look for (a provider's marks, month and key names,
51//! elisions, number words, stop words) are a `Lexicon`: the spam lists of the
52//! language packs in use, and your changes (`words::SpamWords`). A table keeps
53//! the lexicon it was trained with (`table::Meta::lexicon`) and every device
54//! reads each message with it (`tokens_with`), so a change of words counts
55//! from the next training, on every device at once; a table made before
56//! 8 October 2026 keeps none and reads with the packs built in, French and
57//! English, which are the words that made it. A change of words does not
58//! raise `TOKENIZER`; a change of a step still does. The modifier keys of a
59//! shortcut, the ordinal suffixes, the units and the stemmer stay in code.
60//!
61//! Where this port departs from Virtual Secretary on purpose, the line says so
62//! ("Virtual Secretary …"). Tests: `tests` below, on Virtual Secretary's own
63//! inputs (`src/tests/test-patterns.py`).
64
65use crate::words::SpamWords;
66use regex::{Captures, Regex};
67use std::collections::HashSet;
68use std::sync::{Arc, LazyLock, Mutex};
69
70/// The tokenizer's version, stamped in every table (`table.rs`).
71pub const TOKENIZER: u32 = 2;
72
73/// What the tokenizer read in a message.
74#[derive(Debug, Clone, Default, PartialEq, Eq)]
75pub struct Tokens {
76    /// The words, in order, repeats kept: lowercase ASCII, placeholders in
77    /// capitals (`_URL_`, `_PRICE_`…). The language model learns them as one
78    /// line, `words.join(" ")`.
79    pub words: Vec<String>,
80    /// The domain of each link, in order (`registrable`): for the header
81    /// features, never for the language model.
82    pub links: Vec<String>,
83}
84
85/// The words the tokenizer looks for (`words::SpamWords`) and the patterns
86/// made from them (see the module).
87pub struct Lexicon {
88    /// The words, as given.
89    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
103/// A list's words as the steps write the text (`normalize`: lowercase, no
104/// accents, ASCII), without empty ones.
105fn normalized(list: &[String]) -> Vec<String> {
106    list.iter().map(|w| normalize(w).trim().to_string()).filter(|w| !w.is_empty()).collect()
107}
108
109/// An alternation of words, each escaped, the longest first; one that never
110/// matches when there is none (an empty alternation would match anywhere).
111fn 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    /// The patterns made from these words.
119    pub fn new(words: &SpamWords) -> Lexicon {
120        // A provider's mark is read in the subject as written: each word lowercase
121        // (the pattern ignores case), and without its accents ("indesirable").
122        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        // Any single letter after a modifier, as Virtual Secretary read it.
130        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    /// The packs built in, French and English (`words::Words::builtin`):
142    /// the words of every table that keeps none.
143    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    /// The lexicon of these words, made once: its patterns take a moment, and
149    /// a device reads with one table's words at a time.
150    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    /// The subject without the marks a provider put at its start, as many as there are.
164    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
173/// The words of a message, and the domains its links point to, read with
174/// the packs built in (`Lexicon::builtin`).
175pub fn tokens(subject: &str, body: &str) -> Tokens {
176    tokens_with(&Lexicon::builtin(), subject, body)
177}
178
179/// The words of a message, and the domains its links point to, read with
180/// `lexicon` (a table's own: `table::Table::lexicon`).
181pub 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
191// 1. The text.
192
193/// A provider's mark, at the start of a subject: one of its words (`marks`,
194/// an alternation: spam, junk, pourriel, indésirable) between stars,
195/// brackets, braces or parentheses, with what goes with it
196/// ("***Potentiel-SPAM***", "[SPAM?]", "{Spam}", "(Indésirable)"), or "SPAM:".
197fn 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
203/// The subject without the marks a provider put at its start, read with the
204/// packs built in (`Lexicon::without_provider_tags`).
205pub fn without_provider_tags(subject: &str) -> &str {
206    Lexicon::builtin().without_provider_tags(subject)
207}
208
209// 2. Normalization.
210
211/// A text as the placeholders read it: lowercase ASCII, one space between words.
212pub 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
223/// Characters that show nothing: dropped before anything reads words, so that
224/// one put inside a word ("V\u{200b}iagra") neither splits nor hides it.
225/// Sioul's own (`text::readable`), with the marks that turn text around and
226/// the tag and variation characters.
227fn 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
234/// The Latin letter a Cyrillic, Greek or Armenian one mimics, case kept (the
235/// families of `lookalike.rs`, by the letter they pass for).
236fn 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
265/// Lookalikes folded where they hide a Latin word: in a word that holds a
266/// Latin letter ("Pаypal"), or made of nothing but lookalikes ("РАУ"). A word
267/// of another script stays itself, to be dropped with the rest of it (step 2).
268fn 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
289/// One character in ASCII, as Virtual Secretary's table writes it
290/// (`utils.py`, `_unicode_to_ascii`), else its letter without its accent;
291/// nothing when it has no ASCII form.
292fn ascii(c: char, out: &mut String) {
293    let text: &str = match c {
294        // Apostrophes.
295        '\u{2019}' | '`' | '\u{2018}' | '\u{2bc}' | '\u{b4}' | '\u{2032}' => "'",
296        '\u{2033}' => "''",
297        // Spaces, and two private-use bullets of PDF fonts.
298        '\u{f8b9}' | '\u{f81f}' => " ",
299        // Dashes.
300        '\u{2010}'..='\u{2015}' | '\u{ff0d}' | '\u{fe63}' => "-",
301        // Decorations, as spaces; the multiplication sign as x.
302        '↑' | '↵' | '☙' | '❧' | '🔗' | '•' | '©' | '®' | '|' | '¦' | '™' | '·' | '✔' => " ",
303        '×' => "x",
304        'ƒ' => "f",
305        // Ligatures (NFKC already splits most).
306        '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        // Quotes.
323        '«' | '»' | '“' | '”' | '„' => "\"",
324        // Fractions: NFKC writes ½ as 1⁄2, its slash a fraction slash, which
325        // Virtual Secretary then dropped ("12"); written as a slash here.
326        '\u{2044}' => "/",
327        // Arrows.
328        '←' | '⇐' => "<-",
329        '→' | '⇒' => "->",
330        '↔' | '⇔' => "<->",
331        // The symbols Virtual Secretary's patterns read, which dropping all
332        // that is not ASCII would lose: prices, degrees and "n°", the command key.
333        '€' => "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
352/// A Latin letter without its accents ("é" → 'e', "ñ" → 'n', "ä" → 'a'):
353/// the letter its canonical decomposition starts with, when the rest are
354/// accents. Virtual Secretary stripped the French accents only and dropped
355/// the other letters ("señor" → "seor").
356fn 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
364// 3. Cleanup (Virtual Secretary's `characters_cleanup`, in its order).
365
366static DOTS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\.{2,}").expect("dots"));
367// Virtual Secretary made every "~" a dash too, which hid "~/" paths from the placeholders.
368static 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
375/// Runs of dots, dashes and question marks made one; a character repeated ten
376/// times or more, BB code and base64 blocks removed; markup brackets dropped,
377/// their text kept.
378fn 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
390/// A character repeated ten times or more ("==========", "xxxxxxxxxx") made a
391/// space: Virtual Secretary's `(.)\1{9,}`, whose back-reference the `regex` crate lacks.
392fn 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
408// 4. Placeholders.
409
410/// What may stand before a placeholder: the start, a space, an opening
411/// bracket or quote (Virtual Secretary's `regex_starter`).
412const BEFORE: &str = r#"(?:^|[\s\[\(\{<'"`;>])"#;
413/// What may stand after it, end of markup (`regex_stopper`).
414const AFTER: &str = r#"(?:$|[\s\]\)\}>'"`;<])"#;
415/// What may stand after it, end of a word (`end_of_word`).
416const END: &str = r#"(?:$|[\s\]\)\}>'"`;:,\?!\.<])"#;
417/// A sign before a number (`regex_algebra`; its ≠ and ± are gone with what is not ASCII).
418const SIGN: &str = r"[\+\-=]";
419/// A sign, then maybe a space, before a number (`(regex_algebra)? ?`); a sign alone.
420const SIGN_SPACE: &str = r"[\+\-=]? ?";
421const SIGN_ONLY: &str = r"[\+\-=]?";
422/// A number with its separators, as Virtual Secretary's units read it.
423const AMOUNT: &str = r"[0-9]+(?:[\.,\-\+/ ][0-9]*)*?";
424/// What a file name is made of, and what stands around one.
425const NAME: &str = r#"[^#%<>&\*\{\}\\/\?\$!\|="'@\s\x08]"#;
426const NOT_NAME: &str = r#"(?:^|[#%<>&\*\{\}\\/\?\$!\|="'@\s\x08])"#;
427/// After a file name: the end, a space, or one character then the end or a
428/// space (Virtual Secretary's `(?![\.\S]\S)`).
429const 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
432/// One substitution: the part named `m` is replaced, what is matched around it
433/// stays, and the search goes on from the end of `m`.
434struct Pass {
435    re: Regex,
436    with: &'static str,
437    kind: Kind,
438}
439
440#[derive(Clone, Copy, PartialEq, Eq)]
441enum Kind {
442    Plain,
443    /// A link: its host goes to `Tokens::links`.
444    Url,
445    /// Kept only when its check digits are right.
446    Iban,
447    /// Kept only with eight to fifteen digits.
448    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    /// Whether a match is replaced; a link's domain is kept on the way.
480    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
495/// Alternatives longest first: Virtual Secretary's "tif|tiff" took "x.tiff" for "x.tif" and an "f".
496fn 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
507/// A number then a unit, as Virtual Secretary's physical quantities: `head`
508/// before the number (a sign, a space), `unit` after it.
509fn 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
513/// The placeholders' passes, in Virtual Secretary's order; `months` and
514/// `keys`, a lexicon's month and key names as alternations.
515fn 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        // Addresses and handles, before anything splits them at the @.
520        Pass::new(r"(?P<m>[0-9A-Za-z_\-\+\.]*@[0-9A-Za-z_\-\+\.]+|user\-?[0-9]+)", " _USER_ "),
521        // Links (they hold addresses of servers), and "www." ones written without "//".
522        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        // Sioul's own: bank accounts and phone numbers, before their digits read as anything else.
525        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        // Files, by their extension, before paths.
528        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        // Dates, then times.
536        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        // Keyboard shortcuts, before "f4" reads as an aperture.
540        Pass::new(&format!(r"(?i){BEFORE}(?P<m>(?:fn|tab|ctrl|shift|alt|altgr|maj|command|cmd|option|menu)(?: ?\+ ?(?:{keys}))+){END}"), " _SHORTCUT_ "),
541        // Paths, after dates written with slashes. Virtual Secretary's drive
542        // letter was a capital, never found in lowercase text.
543        Pass::new(&format!(r"{BEFORE}(?P<m>(?:[a-z]:\\{{1,2}}|\./|~/|/)(?:{NAME}+(?:\\|/)?)+){END}"), " _PATH_ "),
544        // Five digits or more, one by one or in groups: one long number, read as a hash per word.
545        Pass::new(&format!(r#"(?:^|[\s\['\("])(?P<m>{SIGN}?(?:[0-9]+[\.,/\+\-\s]*){{5,}})(?:$|[\s:;,\?!\]\)'"])"#), "123456789"),
546        // Numbers with their units.
547        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        // Virtual Secretary's alternatives were not grouped: "iso 100" needed no
550        // end, "100 iso" no start. Grouped here.
551        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        // Ordinals: 1st, 2nd, 3e, 1er, 2nde; "n°" (its ° written "deg").
564        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        // Prices, the currency before or after (€ and £ written "eur" and "gbp").
567        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        // Paths without their root, after dates.
570        Pass::new(&format!(r"{BEFORE}(?P<m>(?:{NAME}+(?:\\|/)){{2,}}(?:{NAME}+)?){END}"), " _PATH_ "),
571        // Dashes inside words, as n-grams are written: "e-mail" → "e_mail".
572        Pass::new(r"[0-9A-Za-z_](?P<m>[\-_=]+)[0-9A-Za-z_]", "_"),
573    ]
574}
575
576/// The placeholders, in Virtual Secretary's order; then its last cleanups: a
577/// colon is a space (C++ members), a backslash nothing (LaTeX).
578fn 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
586/// The domain a host belongs to, as registered: its last two labels, or three
587/// under a country's own second level ("co.uk", "gouv.fr", "com.au"). An
588/// approximation of the Public Suffix List, which Sioul does not carry.
589pub 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
604/// Whether an IBAN's check digits are right (ISO 13616: the first four
605/// characters moved to the end, letters as 10 to 35, the number modulo 97 is 1).
606fn 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
619// 5. Splitting.
620
621fn word_byte(b: u8) -> bool {
622    b.is_ascii_alphanumeric() || b == b'_'
623}
624
625/// The words of the text: runs of letters, digits and underscores; a dot or a
626/// comma between two of them kept ("2.5", "1,000", "e.g"), an apostrophe
627/// between two letters ("don't", "aujourd'hui"); French elisions split off
628/// and dropped ("l'information", "qu'il": `Lexicon::elisions`).
629fn 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
657/// A word without its elided articles and pronouns, before a vowel or a
658/// mute h (the lexicon's elisions): "l'information" → "information",
659/// "qu'aujourd'hui" → "aujourd'hui".
660fn 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
671// 6. Per word (`normalize_token`).
672
673/// Every placeholder a word can be.
674pub 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
681// The number words and the stop words are the lexicon's (`Lexicon`): the
682// packs' Virtual Secretary `REPLACEMENTS` and stop words (`language.py`), as
683// the steps above write them (accents stripped, dashes as underscores, elided
684// words without their article: "est" for "c'est"). Single characters are
685// left out with every word of one.
686
687/// Virtual Secretary's `HASH_PATTERN_FAST`, `^[0-9a-f]{6,}$`, took words made
688/// of the letters a to f ("decade", "facade") for hashes: a digit is asked here.
689fn 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    // Virtual Secretary's `NUMBER_PATTERN_FAST`, its groups of thousands repeated ("1.000.000").
696    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
699/// One word as the model learns it, or none (`normalize_token`).
700fn 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    // Words joined by dashes, written with underscores, are n-grams; others lose
712    // the underscores around them (Markdown's _italics_).
713    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
729/// Virtual Secretary's French and English suffix stemmer (`lemmatize`), on
730/// each run of letters and digits of a word, the dots and apostrophes between
731/// them kept: its patterns ended at those as at the word's end. Its first
732/// rule, the elisions, is the splitting's (step 5).
733pub 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    // Each rule: the shortest stem it leaves before the suffix, the suffixes it
755    // tries (the earliest start first, as a regular expression finds them), what replaces them.
756    let rules: &[(usize, &[&str], &str)] = &[
757        // Plurals: lenses → lens; the stem must be letters (PLURAL_S).
758        (4, &["sees", "ees", "ses", "es", "ss", "s"], ""),
759        // British -our: colour → color, not tour.
760        (3, &["our"], "or"),
761        // -ity, -ite: activity → activ (then -tiv: act), not city.
762        (4, &["ity", "ite"], ""),
763        // Feminine -e: capitale → capital.
764        (4, &["ee", "e"], ""),
765        // -trice, -teur, -tor: operateur → operat.
766        (4, &["trice", "teur", "tor"], "t"),
767        // -ed: managed → manag.
768        (4, &["ed"], ""),
769        // -ment, -ement: management → manag.
770        (4, &["ement", "ment"], ""),
771        // -tion, -sion: information → informat (then -at: inform); not action, too short.
772        (4, &["tion", "sion"], "*ion"),
773        // -ist, -ism: feminist → femin.
774        (3, &["ist", "ism"], ""),
775        // -at: optimisat → optimis.
776        (4, &["at"], ""),
777        // -tif, -tiv: actif → act.
778        (2, &["tif", "tiv"], "t"),
779        // -y → i: apply → appli.
780        (3, &["y"], "i"),
781        // -er after five: optimizer → optimiz.
782        (5, &["er"], ""),
783        // -iz, -yz → -is, -ys: optimiz → optimis.
784        (4, &["iz", "yz"], "*s"),
785        // -eur → -or: serveur → servor.
786        (3, &["eur"], "or"),
787        // -iqu, -ic → -i: politiqu → politi.
788        (3, &["iqu", "ic"], "i"),
789    ];
790    for (index, (least, suffixes, with)) in rules.iter().enumerate() {
791        // The longest suffix that leaves a long enough stem: the earliest match.
792        // Plurals need letters before them, not digits (`[a-zA-Z]{4,}`).
793        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            // -tion → -t, -sion → -s; -iz → -is, -yz → -ys: the suffix's first letter stays.
801            "*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
811/// Double consonants made single after the first two letters: commission → comision.
812fn 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    /// The placeholders a text holds once read, in order.
832    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    /// A provider's mark is no word of the message; the same words elsewhere stay.
842    #[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        // test-patterns.py: "Should be caught".
864        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        // "Should be ignored", as times; "12 : 01" was caught by Virtual Secretary too (its reference list).
869        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        // test-patterns.py's URLs, each alone on its line, at the start or after a word.
878        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        // Glued to a word, Virtual Secretary left it; in markup, Markdown and brackets, found.
889        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        // Hash or number, per word (test-patterns.py's first lines).
912        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        // In a text, five numbers or more in a row are one long number, as in Virtual Secretary.
915        assert_eq!(words("2045 e62fabc2 2.5 4.999.23"), ["_NUMBER_", "_HASH_", "_HASH_"]);
916        // Five digits or more are a hash; words of the letters a to f are words.
917        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        // ARCEP's numbers kept for fiction, written every usual way.
924        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        // The usual example of a French IBAN: its check digits hold; one digit off, it is no IBAN.
928        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        // f/8 is an aperture, not a path; a link is a link.
944        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        // As in Virtual Secretary, an amount runs over commas and spaces: "12, 20°C" is one temperature.
948        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        // English contractions stay whole; "it's" is a stop word.
958        assert_eq!(words("Don't wait, it's free"), ["don't", "wait", "free"]);
959        // Accents go, every one: French, Spanish, German.
960        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        // Zero-width characters inside a word neither split nor hide it.
966        assert_eq!(words("V\u{200b}i\u{200c}a\u{200d}g\u{2060}r\u{feff}a"), words("Viagra"));
967        // Cyrillic and Greek letters passing for Latin ones, in a Latin word, and a word of nothing else.
968        assert_eq!(words("Vіаgrа"), words("Viagra"));
969        assert_eq!(words("PаyPаl"), words("PayPal"));
970        assert_eq!(words("РАУ"), words("PAY"));
971        // Mathematical letters and full-width ones read as letters, lowercase.
972        assert_eq!(words("𝐕𝐢𝐚𝐠𝐫𝐚 FREE"), words("viagra free"));
973        // A word of another script is no Latin word: dropped with what is not ASCII.
974        assert_eq!(words("привет"), Vec::<String>::new());
975        // ½ is one half (Virtual Secretary read "12"); ™ the letters TM.
976        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        // As its patterns do, not as its comments say: "action" is too short for
983        // -tion, and "activity" loses -ity, then -tiv.
984        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    /// A lexicon reads with its own words, written as the text is (accents
996    /// off, lowercase); the packs built in do not know them.
997    #[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        // No words at all: nothing taken off, nothing dropped, nothing breaks.
1018        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        // The same words give the same lexicon, made once.
1023        assert!(Arc::ptr_eq(&Lexicon::cached(&words), &Lexicon::cached(&words.clone())));
1024    }
1025}