rotulus_layout/
linkify.rs1use std::ops::Range;
19
20pub const DEFAULT_SCHEMES: &[&str] = &[
25 "http://", "https://", "ftp://", "ftps://", "irc://", "ircs://", "mailto:", "magnet:",
26 "git://", "ssh://", "sftp://",
27];
28
29const BARE_PREFIXES: &[(&str, &str)] = &[
34 ("www.", "https://"),
35 ("ftp.", "ftp://"),
36 ("irc.", "https://"),
37];
38
39#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct Linkifier {
42 schemes: Vec<String>,
44}
45
46impl Default for Linkifier {
47 fn default() -> Self {
48 Linkifier::new(DEFAULT_SCHEMES)
49 }
50}
51
52impl Linkifier {
53 pub fn new<S: AsRef<str>>(schemes: impl IntoIterator<Item = S>) -> Linkifier {
58 let mut out: Vec<String> = Vec::new();
59 for s in schemes {
60 let s = s.as_ref().trim().to_ascii_lowercase();
61 if s.contains(':') && !out.contains(&s) {
62 out.push(s);
63 }
64 }
65 Linkifier { schemes: out }
66 }
67
68 pub fn schemes(&self) -> &[String] {
70 &self.schemes
71 }
72
73 fn scheme_at(&self, bytes: &[u8]) -> Option<usize> {
74 self.schemes
75 .iter()
76 .find(|s| starts_with_ignore_case(bytes, s.as_bytes()))
77 .map(|s| s.len())
78 }
79
80 fn bare_prefix_at(bytes: &[u8]) -> Option<(usize, &'static str)> {
81 BARE_PREFIXES
82 .iter()
83 .find(|(p, _)| starts_with_ignore_case(bytes, p.as_bytes()))
84 .map(|(p, scheme)| (p.len(), *scheme))
85 }
86
87 pub fn allows(&self, href: &str) -> bool {
91 self.scheme_at(href.trim().as_bytes()).is_some()
92 }
93
94 pub fn has_scheme(&self, word: &str) -> bool {
96 let b = word.as_bytes();
97 self.scheme_at(b).is_some() || Self::bare_prefix_at(b).is_some()
98 }
99
100 pub fn is_url(&self, word: &str) -> bool {
103 self.has_scheme(word) || is_email(word)
104 }
105
106 pub fn normalize(&self, word: &str) -> String {
110 let b = word.as_bytes();
111 if self.scheme_at(b).is_some() {
112 return word.to_string();
113 }
114 if let Some((_, scheme)) = Self::bare_prefix_at(b) {
115 return format!("{scheme}{word}");
116 }
117 if let (Some(at), Some(dot)) = (word.find('@'), word.rfind('.')) {
118 if at < dot {
119 return format!("mailto:{word}");
120 }
121 }
122 word.to_string()
123 }
124
125 pub fn scan(&self, text: &str) -> Vec<Range<usize>> {
134 let mut out = self.scan_urls(text);
135 let emails = scan_emails(text, &out);
136 if !emails.is_empty() {
137 out.extend(emails);
138 out.sort_by_key(|r| r.start);
139 }
140 out
141 }
142
143 fn scan_urls(&self, text: &str) -> Vec<Range<usize>> {
144 let bytes = text.as_bytes();
145 let mut out = Vec::new();
146 let mut i = 0usize;
147 while i < bytes.len() {
148 let at_boundary = i == 0
149 || matches!(bytes[i - 1], b'<' | b'(' | b'[' | b'"' | b'\'')
150 || bytes[i - 1].is_ascii_whitespace();
151 let prefix = if at_boundary {
152 self.scheme_at(&bytes[i..])
153 .or_else(|| Self::bare_prefix_at(&bytes[i..]).map(|(n, _)| n))
154 } else {
155 None
156 };
157 let Some(prefix) = prefix else {
158 i += 1;
159 continue;
160 };
161 let mut end = i + prefix;
162 while end < bytes.len()
163 && !matches!(
164 bytes[end],
165 b' ' | b'\t' | b'\n' | b'\r' | b'<' | b'>' | b'"' | b'\''
166 )
167 {
168 end += 1;
169 }
170 let len = trim_trailing_punct(&bytes[i..end]);
171 if len > prefix {
172 out.push(i..i + len);
173 }
174 i = end;
175 }
176 out
177 }
178}
179
180fn starts_with_ignore_case(hay: &[u8], prefix: &[u8]) -> bool {
181 hay.len() >= prefix.len() && hay[..prefix.len()].eq_ignore_ascii_case(prefix)
182}
183
184fn scan_emails(text: &str, taken: &[Range<usize>]) -> Vec<Range<usize>> {
186 let bytes = text.as_bytes();
187 let opens = |c: u8| c.is_ascii_whitespace() || matches!(c, b'<' | b'(' | b'[' | b'"' | b'\'');
188 let closes = |c: u8| c.is_ascii_whitespace() || matches!(c, b'<' | b'>' | b'"' | b'\'');
189 let mut out: Vec<Range<usize>> = Vec::new();
190 for (at, _) in text.match_indices('@') {
191 if taken.iter().chain(&out).any(|r| r.contains(&at)) {
192 continue;
193 }
194 let start = bytes[..at]
195 .iter()
196 .rposition(|&c| opens(c))
197 .map_or(0, |i| i + 1);
198 let end = bytes[at..]
199 .iter()
200 .position(|&c| closes(c))
201 .map_or(bytes.len(), |i| at + i);
202 let len = trim_trailing_punct(&bytes[start..end]);
203 let word = &text[start..start + len];
204 if start < at && is_email(word) && !word.contains('/') && word.matches('@').count() == 1 {
207 out.push(start..start + len);
208 }
209 }
210 out
211}
212
213fn is_email(word: &str) -> bool {
214 match (word.find('@'), word.rfind('.')) {
215 (Some(at), Some(dot)) => at < dot && dot < word.len() - 1,
216 _ => false,
217 }
218}
219
220fn trim_trailing_punct(s: &[u8]) -> usize {
222 let mut len = s.len();
223 while len > 0 {
224 let c = s[len - 1];
225 let strip = matches!(
226 c,
227 b'.' | b',' | b';' | b':' | b'!' | b'?' | b')' | b']' | b'\'' | b'"'
228 );
229 if !strip {
230 break;
231 }
232 if c == b')' && s[..len].contains(&b'(') {
233 break;
234 }
235 if c == b']' && s[..len].contains(&b'[') {
236 break;
237 }
238 len -= 1;
239 }
240 len
241}