1pub fn fold_char(c: char) -> char {
14 match c {
15 'à' | 'â' | 'ä' | 'á' | 'ã' | 'å' | 'À' | 'Â' | 'Ä' | 'Á' | 'Ã' | 'Å' | 'æ' | 'Æ' => 'a',
18 'ç' | 'Ç' => 'c',
19 'é' | 'è' | 'ê' | 'ë' | 'É' | 'È' | 'Ê' | 'Ë' => 'e',
20 'î' | 'ï' | 'í' | 'ì' | 'Î' | 'Ï' | 'Í' | 'Ì' => 'i',
21 'ñ' | 'Ñ' => 'n',
22 'ô' | 'ö' | 'ó' | 'ò' | 'õ' | 'ø' | 'Ô' | 'Ö' | 'Ó' | 'Ò' | 'Õ' | 'Ø' | 'œ' | 'Œ' => 'o',
23 'ù' | 'û' | 'ü' | 'ú' | 'Ù' | 'Û' | 'Ü' | 'Ú' => 'u',
24 'ÿ' | 'ý' | 'Ÿ' | 'Ý' => 'y',
25 '\u{2019}' | '\u{2018}' => '\'',
27 '\u{a0}' | '\u{202f}' => ' ',
28 _ => c.to_lowercase().next().unwrap_or(c),
29 }
30}
31
32pub fn fold(text: &str) -> Vec<char> {
34 text.chars().map(fold_char).collect()
35}
36
37pub fn is_word_char(c: char) -> bool {
39 c.is_alphanumeric()
40}
41
42pub 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
58pub fn contains_word(text: &str, needle: &str) -> bool {
60 find_word(&fold(text), needle, 0).is_some()
61}
62
63pub fn readable(text: &str) -> String {
70 let chars: Vec<char> = text.chars().collect();
71 let mut out = String::with_capacity(text.len());
72 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 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 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
123fn 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
129fn 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
147pub 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 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 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 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 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}