1use super::Finder;
8use crate::domain::looks_like_tld;
9use std::ops::Range;
10
11#[derive(Default)]
12pub struct Handle {}
13
14#[inline]
15fn is_handle_char(b: u8) -> bool {
16 b.is_ascii_alphanumeric() || b == b'_' || b == b'-'
17}
18
19fn is_valid_host(host: &[u8]) -> bool {
22 let mut labels = 0usize;
23 let mut last: &[u8] = &[];
24 for label in host.split(|&b| b == b'.') {
25 if label.is_empty() {
26 return false;
27 }
28 labels += 1;
29 last = label;
30 }
31 labels >= 2 && looks_like_tld(last)
32}
33
34impl Finder for Handle {
35 fn line_agnostic(&self) -> bool {
36 true
39 }
40
41 fn id(&self) -> &'static str {
42 "handle"
43 }
44
45 fn dispatchable(&self) -> bool {
46 true
47 }
48
49 fn could_start_at(&self, byte: u8) -> bool {
50 byte == b'@'
51 }
52
53 fn could_start_after(&self, prev: u8, _cur: u8) -> bool {
54 !(prev.is_ascii_alphanumeric() || prev == b'.' || prev == b'_')
55 }
56
57 fn could_continue_with(&self, _cur: u8, next: u8) -> bool {
58 next.is_ascii_alphanumeric()
59 }
60
61 fn try_at(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
62 if input.get(pos)? != &b'@' {
63 return None;
64 }
65 if pos > 0 {
67 let prev = input[pos - 1];
68 if prev.is_ascii_alphanumeric() || prev == b'.' || prev == b'_' {
69 return None;
70 }
71 }
72 let start = pos;
73 let name_start = pos + 1;
74 if name_start >= input.len() || !input[name_start].is_ascii_alphanumeric() {
75 return None;
76 }
77 let mut end = name_start + 1;
78 while end < input.len() && is_handle_char(input[end]) {
79 end += 1;
80 }
81 while end > name_start && input[end - 1] == b'-' {
83 end -= 1;
84 }
85 let len = end - name_start;
86 if !(1..=39).contains(&len) {
87 return None;
88 }
89
90 if end < input.len() && input[end] == b'@' {
92 let host_start = end + 1;
93 let mut host_end = host_start;
94 while host_end < input.len() {
95 let b = input[host_end];
96 if b.is_ascii_alphanumeric() || b == b'-' || b == b'.' {
97 host_end += 1;
98 } else {
99 break;
100 }
101 }
102 while host_end > host_start
104 && (input[host_end - 1] == b'.' || input[host_end - 1] == b'-')
105 {
106 host_end -= 1;
107 }
108 if is_valid_host(&input[host_start..host_end]) {
112 end = host_end;
113 }
114 }
115
116 Some(start..end)
117 }
118
119 fn find(&self, s: &str) -> Option<Range<usize>> {
120 let input = s.as_bytes();
121 let mut i = 0;
122 while i < input.len() {
123 if input[i] == b'@'
124 && let Some(r) = self.try_at(input, i)
125 {
126 return Some(r);
127 }
128 i += 1;
129 }
130 None
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 #[test]
139 fn id_should_return_handle() {
140 assert_eq!("handle", Handle::default().id());
141 }
142
143 #[test]
144 fn find_simple_handle() {
145 let f = Handle::default();
146 let input = "ping @alice please";
147 let r = f.find(input).unwrap();
148 assert_eq!("@alice", &input[r]);
149 }
150
151 #[test]
152 fn find_github_style() {
153 let f = Handle::default();
154 let input = "cc @user-name on this";
155 let r = f.find(input).unwrap();
156 assert_eq!("@user-name", &input[r]);
157 }
158
159 #[test]
160 fn find_mastodon_style() {
161 let f = Handle::default();
162 let input = "follow @alice@example.social to see";
163 let r = f.find(input).unwrap();
164 assert_eq!("@alice@example.social", &input[r]);
165 }
166
167 #[test]
168 fn find_skips_email() {
169 let f = Handle::default();
170 assert!(f.find("user@example.com").is_none());
171 }
172
173 #[test]
174 fn find_handle_at_start() {
175 let f = Handle::default();
176 let input = "@bob hi";
177 let r = f.find(input).unwrap();
178 assert_eq!("@bob", &input[r]);
179 }
180
181 #[test]
182 fn find_rejects_lone_at() {
183 let f = Handle::default();
184 assert!(f.find("@ alone").is_none());
185 }
186
187 #[test]
188 fn find_rejects_trailing_hyphen() {
189 let f = Handle::default();
190 let input = "@bob- here";
191 let r = f.find(input).unwrap();
192 assert_eq!("@bob", &input[r]);
193 }
194
195 #[test]
196 fn find_returns_none_for_empty() {
197 assert!(Handle::default().find("").is_none());
198 }
199
200 #[test]
203 fn find_handle_with_numbers() {
204 let f = Handle::default();
205 let input = "@user42 done";
206 let r = f.find(input).unwrap();
207 assert_eq!("@user42", &input[r]);
208 }
209
210 #[test]
211 fn find_handle_with_underscore() {
212 let f = Handle::default();
213 let input = "ping @snake_case";
214 let r = f.find(input).unwrap();
215 assert_eq!("@snake_case", &input[r]);
216 }
217
218 #[test]
219 fn find_handle_starting_with_digit() {
220 let f = Handle::default();
221 let input = "@42alice hi";
222 let r = f.find(input).unwrap();
223 assert_eq!("@42alice", &input[r]);
224 }
225
226 #[test]
229 fn find_handle_at_max_length() {
230 let f = Handle::default();
231 let name = "a".repeat(39);
232 let input = format!("hi @{} hi", name);
233 let r = f.find(&input).unwrap();
234 assert_eq!(format!("@{}", name), input[r]);
235 }
236
237 #[test]
238 fn find_handle_over_max_length_truncates_to_max() {
239 let f = Handle::default();
243 let name = "a".repeat(40);
244 let input = format!("@{}", name);
245 assert!(f.find(&input).is_none());
246 }
247
248 #[test]
251 fn find_skips_handle_after_word_char() {
252 let f = Handle::default();
253 assert!(f.find("abc@bob").is_none());
255 }
256
257 #[test]
258 fn find_skips_handle_after_dot() {
259 let f = Handle::default();
260 assert!(f.find(".alice@bob").is_none());
262 }
263
264 #[test]
265 fn find_handle_after_punctuation() {
266 let f = Handle::default();
267 let input = "[@alice]";
268 let r = f.find(input).unwrap();
269 assert_eq!("@alice", &input[r]);
270 }
271
272 #[test]
273 fn find_handle_after_comma() {
274 let f = Handle::default();
275 let input = "cc, @bob";
276 let r = f.find(input).unwrap();
277 assert_eq!("@bob", &input[r]);
278 }
279
280 #[test]
283 fn find_mastodon_with_subdomain() {
284 let f = Handle::default();
285 let input = "@user@mastodon.example.social";
286 let r = f.find(input).unwrap();
287 assert_eq!("@user@mastodon.example.social", &input[r]);
288 }
289
290 #[test]
291 fn find_mastodon_without_dot_in_host_rejects_suffix() {
292 let f = Handle::default();
293 let input = "@user@localhost";
295 let r = f.find(input).unwrap();
296 assert_eq!("@user", &input[r]);
297 }
298
299 #[test]
300 fn find_mastodon_trailing_dot_excluded() {
301 let f = Handle::default();
302 let input = "@user@example.social.";
303 let r = f.find(input).unwrap();
304 assert_eq!("@user@example.social", &input[r]);
305 }
306
307 #[test]
310 fn find_multiple_handles_iteratively() {
311 let f = Handle::default();
312 let input = "@alice and @bob and @carol";
313 let mut results = Vec::new();
314 let mut idx = 0;
315 while idx < input.len() {
316 if let Some(r) = f.find(&input[idx..]) {
317 results.push(input[idx + r.start..idx + r.end].to_string());
318 idx += r.end;
319 } else {
320 break;
321 }
322 }
323 assert_eq!(vec!["@alice", "@bob", "@carol"], results);
324 }
325
326 #[test]
327 fn find_adjacent_handles() {
328 let f = Handle::default();
329 let input = "@a@b cool";
331 let r = f.find(input).unwrap();
332 assert_eq!("@a", &input[r]);
333 }
334
335 #[test]
338 fn find_rejects_handle_with_only_underscore() {
339 let f = Handle::default();
340 assert!(f.find("@_alice").is_none());
342 }
343
344 #[test]
345 fn find_rejects_handle_with_only_hyphen() {
346 let f = Handle::default();
347 assert!(f.find("@-alice").is_none());
348 }
349
350 #[test]
351 fn find_handle_in_email_address_skipped() {
352 let f = Handle::default();
354 let input = "send to user@example.com today";
355 assert!(f.find(input).is_none());
356 }
357
358 #[test]
359 fn find_handle_after_newline_break() {
360 let f = Handle::default();
361 let input = "hello\n@bob";
362 let r = f.find(input).unwrap();
363 assert_eq!("@bob", &input[r]);
364 }
365
366 #[test]
367 fn find_handle_with_unicode_after() {
368 let f = Handle::default();
370 let input = "@alice 🎉";
371 let r = f.find(input).unwrap();
372 assert_eq!("@alice", &input[r]);
373 }
374}