1use super::Finder;
2use crate::domain::{glued_to_two_byte_char, is_label_byte, looks_like_tld};
3use std::ops::Range;
4
5const LOCAL_CHARS: [bool; 256] = {
8 let mut table = [false; 256];
9 let mut i = 0u16;
10 while i < 256 {
11 let b = i as u8;
12 table[i as usize] = b.is_ascii_alphanumeric()
13 || matches!(
14 b,
15 b'.' | b'!'
16 | b'#'
17 | b'$'
18 | b'%'
19 | b'&'
20 | b'\''
21 | b'*'
22 | b'+'
23 | b'/'
24 | b'?'
25 | b'^'
26 | b'_'
27 | b'{'
28 | b'|'
29 | b'}'
30 | b'~'
31 | b'-'
32 );
33 i += 1;
34 }
35 table
36};
37
38#[derive(Default)]
39pub struct Email {}
40
41impl Finder for Email {
42 fn id(&self) -> &'static str {
43 "email"
44 }
45
46 fn triggerable(&self) -> bool {
47 true
48 }
49
50 fn line_agnostic(&self) -> bool {
51 true
53 }
54
55 fn could_trigger_at(&self, byte: u8) -> bool {
56 byte == b'@'
57 }
58
59 fn try_trigger_at(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
60 Self::try_at_at(input, pos)
61 }
62
63 fn find(&self, s: &str) -> Option<Range<usize>> {
64 let input = s.as_bytes();
65 let mut idx = 0;
66
67 while idx < input.len() {
68 let at_pos = idx + input[idx..].iter().position(|&b| b == b'@')?;
69 if let Some(range) = Self::try_at_at(input, at_pos) {
70 return Some(range);
71 }
72 idx = at_pos + 1;
73 }
74
75 None
76 }
77}
78
79impl Email {
80 fn try_at_at(input: &[u8], at_pos: usize) -> Option<Range<usize>> {
81 if input[at_pos] != b'@' {
82 return None;
83 }
84
85 let mut local_start = at_pos;
86 while local_start > 0 && LOCAL_CHARS[input[local_start - 1] as usize] {
87 local_start -= 1;
88 }
89 while local_start < at_pos
92 && input[local_start] != b'.'
93 && !input[local_start].is_ascii_alphanumeric()
94 {
95 local_start += 1;
96 }
97
98 if local_start == at_pos || input[local_start] == b'.' || input[at_pos - 1] == b'.' {
100 return None;
101 }
102
103 if local_start > 0 && input[local_start - 1] == b'@' {
107 return None;
108 }
109
110 if glued_to_two_byte_char(input, local_start) {
113 return None;
114 }
115
116 if input[local_start..at_pos].windows(2).any(|w| w == b"..") {
118 return None;
119 }
120
121 let domain_start = at_pos + 1;
124 let mut domain_end = domain_start;
125 let mut dot_count = 0u32;
126 let mut last_dot = 0usize;
127 let mut label_start = domain_start;
128
129 while domain_end < input.len() {
130 let b = input[domain_end];
131 if b == b'.' {
132 if domain_end + 1 >= input.len() || !is_label_byte(input[domain_end + 1]) {
136 break;
137 }
138 let label_len = domain_end - label_start;
139 if label_len == 0
140 || label_len > 63
141 || input[label_start] == b'-'
142 || input[domain_end - 1] == b'-'
143 {
144 return None;
145 }
146 dot_count += 1;
147 last_dot = domain_end;
148 label_start = domain_end + 1;
149 domain_end += 1;
150 } else if is_label_byte(b) {
151 domain_end += 1;
152 } else {
153 break;
154 }
155 }
156
157 if domain_end == domain_start {
158 return None;
159 }
160
161 while domain_end > domain_start && matches!(input[domain_end - 1], b'.' | b'-') {
163 if input[domain_end - 1] == b'.' && domain_end - 1 == last_dot {
164 dot_count -= 1;
165 if dot_count > 0 {
166 last_dot = input[domain_start..domain_end - 1]
167 .iter()
168 .rposition(|&b| b == b'.')
169 .map(|p| domain_start + p)
170 .unwrap_or(0);
171 }
172 }
173 domain_end -= 1;
174 }
175
176 if dot_count == 0 {
177 return None;
178 }
179
180 let final_label_start = if last_dot >= domain_start {
182 last_dot + 1
183 } else {
184 domain_start
185 };
186 let final_label_len = domain_end - final_label_start;
187 if final_label_len == 0 || input[final_label_start] == b'-' || input[domain_end - 1] == b'-'
188 {
189 return None;
190 }
191
192 let tld = &input[last_dot + 1..domain_end];
194 if !looks_like_tld(tld) {
195 return None;
196 }
197
198 Some(local_start..domain_end)
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn id_should_return_email() {
208 let finder = Email::default();
209 assert_eq!("email", finder.id());
210 }
211
212 #[test]
213 fn find_should_stop_the_local_part_at_an_assignment() {
214 let finder = Email::default();
215 for input in [
216 "git commit --author=ops@example.com",
217 "EMAIL=ops@example.com",
218 ] {
219 let range = finder.find(input).unwrap();
220 assert_eq!("ops@example.com", &input[range], "{input}");
221 }
222 }
223
224 #[test]
225 fn find_should_extract_simple_email() {
226 let finder = Email::default();
227 let input = "contact user@example.com for info";
228 let range = finder.find(input).unwrap();
229 assert_eq!("user@example.com", &input[range]);
230 }
231
232 #[test]
233 fn find_should_extract_email_at_start() {
234 let finder = Email::default();
235 let input = "alice@example.org is here";
236 let range = finder.find(input).unwrap();
237 assert_eq!("alice@example.org", &input[range]);
238 }
239
240 #[test]
241 fn find_should_extract_email_at_end() {
242 let finder = Email::default();
243 let input = "send to bob@test.co";
244 let range = finder.find(input).unwrap();
245 assert_eq!("bob@test.co", &input[range]);
246 }
247
248 #[test]
249 fn find_should_handle_plus_addressing() {
250 let finder = Email::default();
251 let input = "user+tag@example.com";
252 let range = finder.find(input).unwrap();
253 assert_eq!("user+tag@example.com", &input[range]);
254 }
255
256 #[test]
257 fn find_should_handle_dots_in_local_part() {
258 let finder = Email::default();
259 let input = "first.last@example.com";
260 let range = finder.find(input).unwrap();
261 assert_eq!("first.last@example.com", &input[range]);
262 }
263
264 #[test]
265 fn find_should_handle_subdomain() {
266 let finder = Email::default();
267 let input = "user@mail.example.co.uk";
268 let range = finder.find(input).unwrap();
269 assert_eq!("user@mail.example.co.uk", &input[range]);
270 }
271
272 #[test]
273 fn find_should_handle_hyphenated_domain() {
274 let finder = Email::default();
275 let input = "user@my-company.example.com";
276 let range = finder.find(input).unwrap();
277 assert_eq!("user@my-company.example.com", &input[range]);
278 }
279
280 #[test]
281 fn find_should_reject_no_at_sign() {
282 let finder = Email::default();
283 assert!(finder.find("not an email").is_none());
284 }
285
286 #[test]
287 fn find_should_reject_no_domain_dot() {
288 let finder = Email::default();
289 assert!(finder.find("user@localhost").is_none());
290 }
291
292 #[test]
293 fn find_should_reject_empty_local_part() {
294 let finder = Email::default();
295 assert!(finder.find("@example.com").is_none());
296 }
297
298 #[test]
299 fn find_should_reject_dot_start_local() {
300 let finder = Email::default();
301 assert!(finder.find(".user@example.com").is_none());
302 }
303
304 #[test]
305 fn find_should_reject_dot_end_local() {
306 let finder = Email::default();
307 assert!(finder.find("user.@example.com").is_none());
308 }
309
310 #[test]
311 fn find_should_reject_single_char_tld() {
312 let finder = Email::default();
313 assert!(finder.find("user@example.x").is_none());
314 }
315
316 #[test]
317 fn find_should_reject_numeric_tld() {
318 let finder = Email::default();
319 assert!(finder.find("user@example.123").is_none());
320 }
321
322 #[test]
323 fn find_should_reject_domain_starting_with_hyphen() {
324 let finder = Email::default();
325 assert!(finder.find("user@-example.com").is_none());
326 }
327
328 #[test]
329 fn find_should_reject_domain_ending_with_hyphen() {
330 let finder = Email::default();
331 assert!(finder.find("user@example-.com").is_none());
332 }
333
334 #[test]
335 fn find_should_extract_email_from_angle_brackets() {
336 let finder = Email::default();
337 let input = "Author <author@example.com>";
338 let range = finder.find(input).unwrap();
339 assert_eq!("author@example.com", &input[range]);
340 }
341
342 #[test]
343 fn find_should_extract_multiple_emails_iteratively() {
344 let finder = Email::default();
345 let input = "cc: alice@one.com and bob@two.org";
346
347 let mut results = Vec::new();
348 let mut idx = 0;
349 while idx < input.len() {
350 if let Some(range) = finder.find(&input[idx..]) {
351 results.push(&input[idx + range.start..idx + range.end]);
352 idx += range.end;
353 } else {
354 break;
355 }
356 }
357
358 assert_eq!(vec!["alice@one.com", "bob@two.org"], results);
359 }
360
361 #[test]
362 fn find_should_extract_email_in_parentheses() {
363 let finder = Email::default();
364 let input = "(support@example.com)";
365 let range = finder.find(input).unwrap();
366 assert_eq!("support@example.com", &input[range]);
367 }
368
369 #[test]
370 fn find_should_handle_empty_input() {
371 let finder = Email::default();
372 assert!(finder.find("").is_none());
373 }
374
375 #[test]
376 fn find_should_handle_bare_at() {
377 let finder = Email::default();
378 assert!(finder.find("@").is_none());
379 }
380
381 #[test]
382 fn find_should_skip_invalid_and_find_valid() {
383 let finder = Email::default();
384 let input = "@invalid then real@example.com";
385 let range = finder.find(input).unwrap();
386 assert_eq!("real@example.com", &input[range]);
387 }
388
389 #[test]
392 fn find_should_handle_domain_with_many_labels() {
393 let finder = Email::default();
394 let input = "user@a.b.c.d.example.com";
395 let range = finder.find(input).unwrap();
396 assert_eq!("user@a.b.c.d.example.com", &input[range]);
397 }
398
399 #[test]
400 fn find_should_reject_consecutive_dots_in_domain() {
401 let finder = Email::default();
402 assert!(finder.find("user@example..com").is_none());
403 }
404
405 #[test]
406 fn find_should_strip_trailing_dot_from_domain() {
407 let finder = Email::default();
408 let input = "user@example.com.";
409 let range = finder.find(input).unwrap();
410 assert_eq!("user@example.com", &input[range]);
411 }
412
413 #[test]
414 fn find_should_handle_trailing_dot_strip_and_still_validate() {
415 let finder = Email::default();
416 let input = "user@example.com. next";
417 let range = finder.find(input).unwrap();
418 assert_eq!("user@example.com", &input[range]);
419 }
420
421 #[test]
422 fn find_should_reject_label_starting_with_hyphen_deep() {
423 let finder = Email::default();
424 assert!(finder.find("user@sub.-example.com").is_none());
425 }
426
427 #[test]
428 fn find_should_reject_label_ending_with_hyphen_deep() {
429 let finder = Email::default();
430 assert!(finder.find("user@sub.example-.com").is_none());
431 }
432
433 #[test]
434 fn find_should_handle_max_length_tld() {
435 let finder = Email::default();
436 let input = "user@example.museum";
437 let range = finder.find(input).unwrap();
438 assert_eq!("user@example.museum", &input[range]);
439 }
440
441 #[test]
442 fn find_should_extract_email_adjacent_to_punctuation() {
443 let finder = Email::default();
444 let input = "email:user@example.com,next";
445 let range = finder.find(input).unwrap();
446 assert_eq!("user@example.com", &input[range]);
447 }
448}