1use super::{Finder, Memo};
13use std::ops::Range;
14
15#[inline]
16pub(crate) fn is_label_byte(b: u8) -> bool {
17 b.is_ascii_alphanumeric() || b == b'-'
18}
19
20#[inline]
21pub(crate) fn looks_like_tld(s: &[u8]) -> bool {
22 (2..=24).contains(&s.len()) && s.iter().all(|b| b.is_ascii_alphabetic())
23}
24
25fn is_known_tld(s: &[u8]) -> bool {
29 if !looks_like_tld(s) {
30 return false;
31 }
32 if crate::registry::tlds().contains(s) {
33 return true;
34 }
35 let mut buf = [0u8; 24];
36 let lower = &mut buf[..s.len()];
37 lower.copy_from_slice(s);
38 lower.make_ascii_lowercase();
39 let Ok(tld) = std::str::from_utf8(lower) else {
40 return false;
41 };
42 matches!(
43 tld,
44 "alt" | "example" | "internal" | "invalid" | "local" | "localhost" | "onion" | "test"
45 )
46}
47
48#[inline]
56pub(crate) fn glued_to_two_byte_char(input: &[u8], pos: usize) -> bool {
57 pos >= 2 && input[pos - 1] & 0xC0 == 0x80 && input[pos - 2] & 0xE0 == 0xC0
58}
59
60#[inline]
63fn is_email_local_byte(b: u8) -> bool {
64 b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'%' | b'+' | b'-')
65}
66
67#[inline]
70fn continues_token(prev: u8) -> bool {
71 prev == b'@'
72 || prev == b'/'
73 || prev == b':'
74 || prev.is_ascii_alphanumeric()
75 || prev == b'.'
76 || prev == b'-'
77 || prev == b'_'
78}
79
80enum Eval {
82 Match(usize),
84 Skip(usize),
86}
87
88#[derive(Default)]
89pub struct Domain {}
90
91impl Domain {
92 fn evaluate(input: &[u8], start: usize) -> Eval {
95 let mut end = start;
97 let mut label_start = start;
98 let mut dot_count = 0u32;
99 let mut last_dot = 0usize;
100 let mut valid = true;
101
102 while end < input.len() {
103 let b = input[end];
104 if is_label_byte(b) {
105 end += 1;
106 } else if b == b'.' {
107 if end + 1 >= input.len() || !is_label_byte(input[end + 1]) {
111 break;
112 }
113 let label_len = end - label_start;
114 if label_len == 0
115 || label_len > 63
116 || input[label_start] == b'-'
117 || input[end - 1] == b'-'
118 {
119 valid = false;
120 break;
121 }
122 dot_count += 1;
123 last_dot = end;
124 label_start = end + 1;
125 end += 1;
126 } else {
127 break;
128 }
129 }
130
131 if !valid || dot_count == 0 || end <= last_dot + 1 {
132 return Eval::Skip(if end > start { end + 1 } else { start + 1 });
133 }
134
135 if end < input.len() {
138 let next = input[end];
139 if matches!(next, b'/' | b':' | b'_' | b'(') {
140 return Eval::Skip(end + 1);
141 }
142 let mut probe = end;
147 while probe < input.len() && is_email_local_byte(input[probe]) {
148 probe += 1;
149 }
150 if probe < input.len() && input[probe] == b'@' {
151 return Eval::Skip(probe + 1);
152 }
153 }
154
155 let tld = &input[last_dot + 1..end];
157 if !is_known_tld(tld) {
158 return Eval::Skip(end);
159 }
160
161 if input[start..end]
163 .iter()
164 .all(|b| b.is_ascii_digit() || *b == b'.')
165 {
166 return Eval::Skip(end);
167 }
168
169 Eval::Match(end)
170 }
171
172 fn from_dot(input: &[u8], pos: usize, memo: &mut Memo) -> Option<Range<usize>> {
177 if input[pos] != b'.' {
178 return None;
179 }
180 if pos == 0
184 || !input[pos - 1].is_ascii_alphanumeric()
185 || pos + 1 >= input.len()
186 || !input[pos + 1].is_ascii_alphabetic()
187 {
188 return None;
189 }
190 if memo.covers(pos) {
191 return None;
192 }
193 let mut start = pos;
196 while start > 0 && (is_label_byte(input[start - 1]) || input[start - 1] == b'.') {
197 start -= 1;
198 }
199 let mut run_end = pos + 1;
200 while run_end < input.len() && (is_label_byte(input[run_end]) || input[run_end] == b'.') {
201 run_end += 1;
202 }
203 let ok = input[start].is_ascii_alphanumeric()
204 && (start == 0
205 || (!continues_token(input[start - 1]) && !glued_to_two_byte_char(input, start)));
206 if ok && let Eval::Match(end) = Self::evaluate(input, start) {
207 return Some(start..end);
208 }
209 memo.start = start;
210 memo.end = run_end;
211 None
212 }
213}
214
215impl Finder for Domain {
216 fn id(&self) -> &'static str {
217 "domain"
218 }
219
220 fn triggerable(&self) -> bool {
221 true
222 }
223
224 fn line_agnostic(&self) -> bool {
225 true
228 }
229
230 fn could_trigger_at(&self, byte: u8) -> bool {
231 byte == b'.'
232 }
233
234 fn could_start_after(&self, prev: u8, _cur: u8) -> bool {
235 prev.is_ascii_alphanumeric()
236 }
237
238 fn could_continue_with(&self, _cur: u8, next: u8) -> bool {
239 next.is_ascii_alphabetic()
240 }
241
242 fn try_trigger_at(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
243 Self::from_dot(input, pos, &mut Memo::default())
244 }
245
246 fn try_trigger_at_memo(
247 &self,
248 input: &[u8],
249 pos: usize,
250 memo: &mut Memo,
251 ) -> Option<Range<usize>> {
252 Self::from_dot(input, pos, memo)
253 }
254
255 fn find(&self, s: &str) -> Option<Range<usize>> {
256 let input = s.as_bytes();
257 let mut i = 0;
258
259 while i < input.len() {
260 if !input[i].is_ascii_alphanumeric() {
262 i += 1;
263 continue;
264 }
265
266 if i > 0 && (continues_token(input[i - 1]) || glued_to_two_byte_char(input, i)) {
269 i += 1;
270 continue;
271 }
272
273 match Self::evaluate(input, i) {
274 Eval::Match(end) => return Some(i..end),
275 Eval::Skip(next) => i = next,
276 }
277 }
278
279 None
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn id_should_return_domain() {
289 assert_eq!("domain", Domain::default().id());
290 }
291
292 #[test]
293 fn find_simple_domain() {
294 let f = Domain::default();
295 let input = "visit example.com today";
296 let r = f.find(input).unwrap();
297 assert_eq!("example.com", &input[r]);
298 }
299
300 #[test]
301 fn find_subdomain() {
302 let f = Domain::default();
303 let input = "see mail.example.co.uk for details";
304 let r = f.find(input).unwrap();
305 assert_eq!("mail.example.co.uk", &input[r]);
306 }
307
308 #[test]
309 fn find_skips_email() {
310 let f = Domain::default();
311 assert!(f.find("user@example.com").is_none());
312 }
313
314 #[test]
315 fn find_skips_inside_url() {
316 let f = Domain::default();
317 assert!(f.find("https://example.com").is_none());
318 }
319
320 #[test]
321 fn find_rejects_no_dot() {
322 let f = Domain::default();
323 assert!(f.find("localhost").is_none());
324 }
325
326 #[test]
327 fn find_rejects_ip() {
328 let f = Domain::default();
329 assert!(f.find("192.168.1.1").is_none());
330 }
331
332 #[test]
333 fn find_strips_trailing_dot() {
334 let f = Domain::default();
335 let input = "example.com.";
336 let r = f.find(input).unwrap();
337 assert_eq!("example.com", &input[r]);
338 }
339
340 #[test]
341 fn find_hyphenated_domain() {
342 let f = Domain::default();
343 let input = "go to my-site.example.com okay";
344 let r = f.find(input).unwrap();
345 assert_eq!("my-site.example.com", &input[r]);
346 }
347
348 #[test]
349 fn find_rejects_label_starting_with_hyphen() {
350 let f = Domain::default();
351 assert!(f.find("-bad.example.com").is_none());
352 }
353
354 #[test]
355 fn find_handles_empty() {
356 assert!(Domain::default().find("").is_none());
357 }
358
359 #[test]
362 fn find_domain_at_start() {
363 let f = Domain::default();
364 let input = "example.com is great";
365 let r = f.find(input).unwrap();
366 assert_eq!("example.com", &input[r]);
367 }
368
369 #[test]
370 fn find_domain_at_end() {
371 let f = Domain::default();
372 let input = "see example.com";
373 let r = f.find(input).unwrap();
374 assert_eq!("example.com", &input[r]);
375 }
376
377 #[test]
378 fn find_domain_inside_parens() {
379 let f = Domain::default();
380 let input = "(example.com)";
381 let r = f.find(input).unwrap();
382 assert_eq!("example.com", &input[r]);
383 }
384
385 #[test]
386 fn find_domain_in_markdown_link() {
387 let f = Domain::default();
388 let input = "[link](example.com)";
389 let r = f.find(input).unwrap();
390 assert_eq!("example.com", &input[r]);
391 }
392
393 #[test]
394 fn find_domain_followed_by_comma() {
395 let f = Domain::default();
396 let input = "example.com, foo";
397 let r = f.find(input).unwrap();
398 assert_eq!("example.com", &input[r]);
399 }
400
401 #[test]
402 fn find_domain_followed_by_period_end_of_sentence() {
403 let f = Domain::default();
404 let input = "go to example.com.";
405 let r = f.find(input).unwrap();
406 assert_eq!("example.com", &input[r]);
407 }
408
409 #[test]
410 fn find_domain_followed_by_question_mark() {
411 let f = Domain::default();
412 let input = "is it example.com?";
413 let r = f.find(input).unwrap();
414 assert_eq!("example.com", &input[r]);
415 }
416
417 #[test]
420 fn find_uppercase_domain() {
421 let f = Domain::default();
422 let input = "go to EXAMPLE.COM today";
423 let r = f.find(input).unwrap();
424 assert_eq!("EXAMPLE.COM", &input[r]);
425 }
426
427 #[test]
428 fn find_mixed_case_domain() {
429 let f = Domain::default();
430 let input = "see Example.Com";
431 let r = f.find(input).unwrap();
432 assert_eq!("Example.Com", &input[r]);
433 }
434
435 #[test]
438 fn find_long_subdomain_chain() {
439 let f = Domain::default();
440 let input = "a.b.c.d.e.example.com";
441 let r = f.find(input).unwrap();
442 assert_eq!("a.b.c.d.e.example.com", &input[r]);
443 }
444
445 #[test]
446 fn find_domain_with_numeric_subdomain() {
447 let f = Domain::default();
448 let input = "v2.example.com";
449 let r = f.find(input).unwrap();
450 assert_eq!("v2.example.com", &input[r]);
451 }
452
453 #[test]
454 fn find_rejects_label_ending_with_hyphen() {
455 let f = Domain::default();
456 assert!(f.find("bad-.example.com").is_none());
457 }
458
459 #[test]
460 fn find_rejects_consecutive_dots() {
461 let f = Domain::default();
462 assert!(f.find("foo..bar.com").is_none());
463 }
464
465 #[test]
466 fn find_rejects_tld_too_short() {
467 let f = Domain::default();
468 assert!(f.find("foo.x").is_none());
469 }
470
471 #[test]
472 fn find_rejects_numeric_tld() {
473 let f = Domain::default();
474 assert!(f.find("foo.123").is_none());
475 }
476
477 #[test]
480 fn find_multiple_domains_iteratively() {
481 let f = Domain::default();
482 let input = "example.com and other.org";
483 let mut results = Vec::new();
484 let mut idx = 0;
485 while idx < input.len() {
486 if let Some(r) = f.find(&input[idx..]) {
487 results.push(&input[idx + r.start..idx + r.end]);
488 idx += r.end;
489 } else {
490 break;
491 }
492 }
493 assert_eq!(vec!["example.com", "other.org"], results);
494 }
495
496 #[test]
499 fn find_skips_inside_path() {
500 let f = Domain::default();
501 assert!(f.find("/usr/example.com/files").is_none());
503 }
504
505 #[test]
506 fn find_skips_ipv6_like() {
507 let f = Domain::default();
508 assert!(f.find("2001:db8::1").is_none());
509 }
510
511 #[test]
512 fn find_skips_when_followed_by_at() {
513 let f = Domain::default();
515 assert!(f.find("foo.com@bar").is_none());
516 }
517
518 #[test]
519 fn find_skips_when_followed_by_path() {
520 let f = Domain::default();
521 assert!(f.find("example.com/path").is_none());
523 }
524
525 #[test]
526 fn find_skips_when_followed_by_port() {
527 let f = Domain::default();
528 assert!(f.find("example.com:8080").is_none());
530 }
531
532 #[test]
535 fn find_long_alpha_tld() {
536 let f = Domain::default();
537 let input = "example.museum";
538 let r = f.find(input).unwrap();
539 assert_eq!("example.museum", &input[r]);
540 }
541
542 #[test]
543 fn find_two_letter_country_tld() {
544 let f = Domain::default();
545 let input = "see example.uk";
546 let r = f.find(input).unwrap();
547 assert_eq!("example.uk", &input[r]);
548 }
549
550 #[test]
553 fn find_matches_full_token_not_inner() {
554 let f = Domain::default();
555 let input = "abcdexample.com";
558 let r = f.find(input).unwrap();
559 assert_eq!(0..input.len(), r);
560 }
561
562 #[test]
563 fn find_skips_glued_to_underscore_prefix() {
564 let f = Domain::default();
565 assert!(f.find("_example.com").is_none());
566 }
567
568 #[test]
569 fn find_skips_in_dotted_chain_preceded_by_dot() {
570 let f = Domain::default();
571 assert!(f.find(".example.com").is_none());
573 }
574}