Skip to main content

sioul_core/
text.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Text helpers shared by the detectors.
5//!
6//! Comparisons ignore case and accents (French, Spanish, Italian, German,
7//! Portuguese and Nordic ones), so "Réinitialiser" matches "reinitialiser" and
8//! "lunedì" "lunedi". Folding maps one character to exactly one character, so a
9//! position found in the folded text is valid in the original text too: codes
10//! are matched on folded text and copied from the original.
11
12/// Lowercases one character and strips its accent, one character out for one in.
13pub fn fold_char(c: char) -> char {
14    match c {
15        // One character for one, so that positions in the folded text are those
16        // of the text (codes.rs); "ß" stays, as it would become two.
17        'à' | 'â' | 'ä' | 'á' | 'ã' | 'å' | 'À' | 'Â' | 'Ä' | 'Á' | 'Ã' | 'Å' | 'æ' | 'Æ' => 'a',
18        'ç' | 'Ç' => 'c',
19        'é' | 'è' | 'ê' | 'ë' | 'É' | 'È' | 'Ê' | 'Ë' => 'e',
20        'î' | 'ï' | 'í' | 'ì' | 'Î' | 'Ï' | 'Í' | 'Ì' => 'i',
21        'ñ' | 'Ñ' => 'n',
22        'ô' | 'ö' | 'ó' | 'ò' | 'õ' | 'ø' | 'Ô' | 'Ö' | 'Ó' | 'Ò' | 'Õ' | 'Ø' | 'œ' | 'Œ' => 'o',
23        'ù' | 'û' | 'ü' | 'ú' | 'Ù' | 'Û' | 'Ü' | 'Ú' => 'u',
24        'ÿ' | 'ý' | 'Ÿ' | 'Ý' => 'y',
25        // Typographic apostrophes and non-breaking spaces are the norm in French mail.
26        '\u{2019}' | '\u{2018}' => '\'',
27        '\u{a0}' | '\u{202f}' => ' ',
28        _ => c.to_lowercase().next().unwrap_or(c),
29    }
30}
31
32/// The folded characters of a text.
33pub fn fold(text: &str) -> Vec<char> {
34    text.chars().map(fold_char).collect()
35}
36
37/// Whether a character can belong to a word, for word-boundary checks.
38pub fn is_word_char(c: char) -> bool {
39    c.is_alphanumeric()
40}
41
42/// Position of `needle` as a whole word in folded `haystack`, from `from` on.
43///
44/// Whole words only: "otp" must not match inside "hotpot".
45pub fn find_word(haystack: &[char], needle: &str, from: usize) -> Option<usize> {
46    let needle = fold(needle);
47    if needle.is_empty() || haystack.len() < needle.len() {
48        return None;
49    }
50    let last = haystack.len() - needle.len();
51    (from..=last).find(|&i| {
52        haystack[i..i + needle.len()] == needle[..]
53            && (i == 0 || !is_word_char(haystack[i - 1]))
54            && (i + needle.len() == haystack.len() || !is_word_char(haystack[i + needle.len()]))
55    })
56}
57
58/// Whether `text` contains `needle` as a whole word, ignoring case and accents.
59pub fn contains_word(text: &str, needle: &str) -> bool {
60    find_word(&fold(text), needle, 0).is_some()
61}
62
63/// A text as the detectors read it: its web addresses left out (a tracking
64/// link is hundreds of characters long, and its digits are no code), with the
65/// brackets around them; invisible characters left out (the zero-width
66/// joiners that pad a newsletter's preview line); each run of spaces made one
67/// space, or one line break when it holds one. A single space of any kind
68/// stays as it is: "482 913" with a narrow no-break space is still one code.
69pub fn readable(text: &str) -> String {
70    let chars: Vec<char> = text.chars().collect();
71    let mut out = String::with_capacity(text.len());
72    // The run of spaces being read: its first character, its length, whether it breaks the line.
73    let mut run: Option<(char, usize, bool)> = None;
74    let mut i = 0;
75    while i < chars.len() {
76        let c = chars[i];
77        if is_invisible(c) {
78            i += 1;
79            continue;
80        }
81        if let Some(length) = link_at(&chars, i) {
82            // The bracket opened just before it goes with it, and the spaces before that join the run.
83            if run.is_none() && out.ends_with(['<', '(', '[']) {
84                out.pop();
85                while let Some(space) = out.chars().last().filter(|c| c.is_whitespace()) {
86                    out.pop();
87                    run = Some(run.map_or((space, 1, space == '\n'), |(first, n, broken)| (first, n + 1, broken || space == '\n')));
88                }
89            }
90            i += length;
91            if chars.get(i).is_some_and(|c| matches!(c, '>' | ')' | ']')) {
92                i += 1;
93            }
94            // Where it was, the words around stay apart.
95            run = Some(run.map_or((' ', 2, false), |(first, n, broken)| (first, n + 2, broken)));
96            continue;
97        }
98        if c.is_whitespace() {
99            run = Some(match run {
100                None => (c, 1, c == '\n'),
101                Some((first, n, broken)) => (first, n + 1, broken || c == '\n'),
102            });
103            i += 1;
104            continue;
105        }
106        if let Some((first, n, broken)) = run.take()
107            && !out.is_empty()
108        {
109            out.push(if broken {
110                '\n'
111            } else if n == 1 && first != '\t' && first != '\r' {
112                first
113            } else {
114                ' '
115            });
116        }
117        out.push(c);
118        i += 1;
119    }
120    out
121}
122
123/// Characters that show nothing: zero-width spaces and joiners, the word
124/// joiner, the byte order mark, the soft hyphen, the combining grapheme joiner.
125fn is_invisible(c: char) -> bool {
126    matches!(c, '\u{200b}' | '\u{200c}' | '\u{200d}' | '\u{2060}'..='\u{2064}' | '\u{feff}' | '\u{00ad}' | '\u{034f}' | '\u{180e}')
127}
128
129/// The length of the web address starting at `at` ("https://…", "http://…",
130/// "www.…"), its last punctuation left to the sentence; none when there is none.
131fn link_at(chars: &[char], at: usize) -> Option<usize> {
132    if at > 0 && chars[at - 1].is_alphanumeric() {
133        return None;
134    }
135    let starts = |prefix: &str| prefix.chars().enumerate().all(|(k, p)| chars.get(at + k).is_some_and(|c| c.to_ascii_lowercase() == p));
136    let prefix = ["https://", "http://", "www."].into_iter().find(|p| starts(p))?.chars().count();
137    let mut end = at + prefix;
138    while end < chars.len() && !chars[end].is_whitespace() && !matches!(chars[end], '<' | '>' | '"' | '\'') {
139        end += 1;
140    }
141    while end > at + prefix && matches!(chars[end - 1], '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']') {
142        end -= 1;
143    }
144    (end > at + prefix).then_some(end - at)
145}
146
147/// How many characters a text shows, its web addresses and spaces left out.
148pub fn visible_len(text: &str) -> usize {
149    readable(text).chars().filter(|c| !c.is_whitespace()).count()
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn folding_ignores_case_and_accents() {
158        assert!(contains_word("Réinitialiser votre mot de passe", "reinitialiser votre mot de passe"));
159        assert!(contains_word("CODE D\u{2019}ACCÈS", "code d'acces"));
160        // Other languages' letters, one for one; "ß" stays, as it would become two.
161        assert_eq!(fold("Año Lunedì Ørsted Åre São Ýmir").into_iter().collect::<String>(), "ano lunedi orsted are sao ymir");
162        assert_eq!(fold("Straße").len(), "Straße".chars().count());
163        assert!(contains_word("Código de verificación", "codigo de verificacion"));
164    }
165
166    #[test]
167    fn whole_words_only() {
168        assert!(!contains_word("a hotpot recipe", "otp"));
169        assert!(contains_word("your OTP: 123456", "otp"));
170    }
171
172    #[test]
173    fn read_without_links_or_padding() {
174        // Links go, with their brackets, their sentence's stop staying.
175        assert_eq!(readable("Sign in <https://click.example/ls?u=482913&t=1> now."), "Sign in now.");
176        assert_eq!(readable("Open https://example.org/a/b. Then (www.example.org/x) here"), "Open . Then here");
177        // Invisible padding goes; runs of spaces become one, or one line break.
178        assert_eq!(readable("Hello\u{200c}\u{a0}\u{200c}\u{a0}\u{200c}\u{a0}   you\n\n\n   there"), "Hello you\nthere");
179        // A single space of any kind stays as it is: digits printed in groups keep it.
180        assert_eq!(readable("482\u{202f}913"), "482\u{202f}913");
181        assert_eq!(visible_len("  Hi https://example.org/very/long/link  "), 2);
182    }
183}