Skip to main content

squeeze/
uri.rs

1//! URI finder implementation based on [RFC 3986](https://tools.ietf.org/html/rfc3986#appendix-A).
2//!
3//! This module provides a [`URI`] finder that can extract URIs from text, optionally
4//! filtering by scheme (http, https, mailto, etc.).
5//!
6//! # Example
7//!
8//! ```
9//! use squeeze::{uri::URI, Finder};
10//!
11//! let mut finder = URI::default();
12//! finder.add_scheme("https"); // Only match https URLs
13//!
14//! let text = "Visit https://example.com or http://other.com";
15//! if let Some(range) = finder.find(text) {
16//!     assert_eq!(&text[range], "https://example.com");
17//! }
18//! ```
19
20use super::Finder;
21use std::ops::Range;
22
23const SUB_DELIMS_STRICT: [bool; 256] = {
24    let mut table = [false; 256];
25    table[b'!' as usize] = true;
26    table[b'$' as usize] = true;
27    table[b'&' as usize] = true;
28    table[b'\'' as usize] = true;
29    table[b'(' as usize] = true;
30    table[b')' as usize] = true;
31    table[b'*' as usize] = true;
32    table[b'+' as usize] = true;
33    table[b',' as usize] = true;
34    table[b';' as usize] = true;
35    table[b'=' as usize] = true;
36    table
37};
38
39const SUB_DELIMS_LAX: [bool; 256] = {
40    let mut table = [false; 256];
41    table[b'!' as usize] = true;
42    table[b'$' as usize] = true;
43    table[b'&' as usize] = true;
44    table[b'(' as usize] = true;
45    table[b'*' as usize] = true;
46    table[b'+' as usize] = true;
47    table[b',' as usize] = true;
48    table[b';' as usize] = true;
49    table[b'=' as usize] = true;
50    table
51};
52
53/// Byte classes of the URI body, one table per mode: `PCHAR` is
54/// unreserved / sub-delims / ":" / "@" (plus ")" in lax mode), `QUERY` adds
55/// "/" and "?", `USERINFO` is unreserved / sub-delims / ":". Percent
56/// encoding is handled separately.
57const PCHAR: u8 = 1 << 0;
58const QUERY: u8 = 1 << 1;
59const USERINFO: u8 = 1 << 2;
60/// Hostname label bytes: ASCII alphanumerics, `_` and `-`.
61const LABEL: u8 = 1 << 3;
62
63const fn body_classes(strict: bool) -> [u8; 256] {
64    let mut table = [0u8; 256];
65    let mut b = 0usize;
66    while b < 256 {
67        let c = b as u8;
68        let unreserved =
69            c.is_ascii_alphanumeric() || c == b'-' || c == b'.' || c == b'_' || c == b'~';
70        let sub_delim = if strict {
71            SUB_DELIMS_STRICT[b]
72        } else {
73            SUB_DELIMS_LAX[b]
74        };
75        let mut flags = 0u8;
76        if unreserved || sub_delim || c == b':' {
77            flags |= USERINFO;
78        }
79        if unreserved || sub_delim || c == b':' || c == b'@' || (!strict && c == b')') {
80            flags |= PCHAR;
81        }
82        if flags & PCHAR != 0 || c == b'/' || c == b'?' {
83            flags |= QUERY;
84        }
85        if c.is_ascii_alphanumeric() || c == b'_' || c == b'-' {
86            flags |= LABEL;
87        }
88        table[b] = flags;
89        b += 1;
90    }
91    table
92}
93
94static BODY_CLASSES_STRICT: [u8; 256] = body_classes(true);
95static BODY_CLASSES_LAX: [u8; 256] = body_classes(false);
96
97// Covers every IANA-registered scheme (the longest is 36 bytes) so opaque
98// URIs using them are not truncated out of the registry lookup.
99const MAX_SCHEME_LEN: usize = 64;
100
101#[derive(Default, Clone, Copy)]
102struct SchemeConfig(u8);
103
104impl SchemeConfig {
105    fn has(&self, flag: u8) -> bool {
106        (self.0 & flag) != 0
107    }
108}
109
110/// Per-scheme parsing rules, ASCII case ignored.
111fn scheme_config(scheme: &str) -> SchemeConfig {
112    if ["ftp", "http", "https"]
113        .iter()
114        .any(|s| scheme.eq_ignore_ascii_case(s))
115    {
116        SchemeConfig(DISALLOW_EMPTY_HOST)
117    } else {
118        SchemeConfig::default()
119    }
120}
121
122/// Whether the two bytes before a colon can end a registered scheme (ASCII
123/// case-insensitive). In lax mode an unregistered scheme needs `//` after
124/// the colon, so a colon followed by anything else can only start a URI
125/// when this says so: an O(1) rejection of every timestamp and `key:value`
126/// colon before the scheme is walked back.
127fn can_end_registered_scheme(prev2: u8, prev1: u8) -> bool {
128    static TABLE: std::sync::OnceLock<Box<[u64; 1024]>> = std::sync::OnceLock::new();
129    let table = TABLE.get_or_init(|| {
130        let mut table = Box::new([0u64; 1024]);
131        let mut set = |a: u8, b: u8| {
132            let index = usize::from(a) << 8 | usize::from(b);
133            table[index >> 6] |= 1 << (index & 63);
134        };
135        for scheme in crate::iana::URI_SCHEMES.iter() {
136            let bytes = scheme.as_bytes();
137            match bytes {
138                [] => {}
139                [only] => {
140                    for a in 0..=255u8 {
141                        set(a, *only);
142                    }
143                }
144                [.., a, b] => set(*a, *b),
145            }
146        }
147        table
148    });
149    let index =
150        usize::from(prev2.to_ascii_lowercase()) << 8 | usize::from(prev1.to_ascii_lowercase());
151    table[index >> 6] & (1 << (index & 63)) != 0
152}
153
154fn is_registered_scheme(scheme: &str) -> bool {
155    crate::registry::uri_schemes().contains(scheme.as_bytes())
156}
157
158const DISALLOW_EMPTY_HOST: u8 = 1 << 0;
159
160/// A finder that extracts URIs from text according to RFC 3986.
161///
162/// By default, any scheme is matched, subject to the lax-mode heuristics
163/// below. Use [`URI::add_scheme`] to filter by specific schemes.
164///
165/// # Strict Mode
166///
167/// By default, the finder excludes `'` characters, unbalanced trailing `)`
168/// characters (so markdown links `[text](url)` work while
169/// `…/Sport_(disambiguation)` stays intact), and trailing sentence
170/// punctuation (`.,;:!?`) from URIs. It also skips prose and code that
171/// happens to parse as a URI: bare `scheme:` labels, `a::b` paths, and opaque
172/// URIs (no `//` after the colon) whose scheme is neither IANA-registered nor
173/// added with [`URI::add_scheme`]. Set [`URI::strict`] to `true` to strictly
174/// follow RFC 3986.
175#[derive(Default)]
176pub struct URI {
177    schemes: Vec<String>,
178    /// Last bytes of the allowlisted schemes, a 256-bit set.
179    scheme_ends: [u64; 4],
180    /// When `true`, strictly follows RFC 3986: any scheme matches and
181    /// trailing `'`, `)`, and punctuation are kept.
182    /// When `false` (default), applies the text-extraction heuristics above.
183    pub strict: bool,
184}
185
186impl Finder for URI {
187    fn id(&self) -> &'static str {
188        "uri"
189    }
190
191    fn triggerable(&self) -> bool {
192        true
193    }
194
195    fn line_agnostic(&self) -> bool {
196        // Every walk stops at whitespace, which includes line terminators.
197        true
198    }
199
200    fn could_trigger_at(&self, byte: u8) -> bool {
201        byte == b':'
202    }
203
204    fn could_start_after(&self, prev: u8, _cur: u8) -> bool {
205        // `rlook_scheme` needs at least one scheme byte right before the colon.
206        Self::is_alpha(prev) || Self::is_digit(prev) || matches!(prev, b'+' | b'-' | b'.')
207    }
208
209    fn could_continue_with(&self, _cur: u8, next: u8) -> bool {
210        if self.strict {
211            return true;
212        }
213        // Lax mode: a second colon marks a code path, and a byte that cannot
214        // begin a hier-part, query or fragment leaves a prose-only tail.
215        next.is_ascii_alphanumeric()
216            || matches!(
217                next,
218                b'-' | b'.'
219                    | b'_'
220                    | b'~'
221                    | b'%'
222                    | b'!'
223                    | b'$'
224                    | b'&'
225                    | b'\''
226                    | b'('
227                    | b')'
228                    | b'*'
229                    | b'+'
230                    | b','
231                    | b';'
232                    | b'='
233                    | b'@'
234                    | b'/'
235                    | b'?'
236                    | b'#'
237            )
238    }
239
240    fn has_trigger_context(&self) -> bool {
241        // An allowlisted scheme, or in lax mode a registered one unless `//`
242        // follows, ends right before the colon: the two bytes before it
243        // reject most colons, and the scanner tabulates that once.
244        !self.strict || !self.schemes.is_empty()
245    }
246
247    // Both mirror the first tests of `try_at_colon`.
248    fn trigger_context(&self, prev2: u8, prev1: u8) -> bool {
249        if !self.schemes.is_empty() {
250            let (prev2, prev1) = (prev2.to_ascii_lowercase(), prev1.to_ascii_lowercase());
251            if self.scheme_ends[usize::from(prev1 >> 6)] & (1 << (prev1 & 63)) == 0 {
252                return false;
253            }
254            return self.schemes.iter().any(|s| match s.as_bytes() {
255                [] => false,
256                [only] => *only == prev1,
257                [.., a, b] => *a == prev2 && *b == prev1,
258            });
259        }
260        self.strict || can_end_registered_scheme(prev2, prev1)
261    }
262
263    fn trigger_context_exempt(&self, next: Option<u8>) -> bool {
264        self.schemes.is_empty() && next == Some(b'/')
265    }
266
267    fn try_trigger_at(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
268        self.try_at_colon(input, pos)
269    }
270
271    // scheme ":" hier-part [ "?" query ] [ "#" fragment ]
272    fn find(&self, s: &str) -> Option<Range<usize>> {
273        let input = s.as_bytes();
274        let mut idx = 0;
275
276        while idx < input.len() {
277            let colon_idx = idx + input[idx..].iter().position(|&b| b == b':')?;
278            idx = colon_idx + 1;
279            if let Some(range) = self.try_at_colon(input, colon_idx) {
280                return Some(range);
281            }
282        }
283
284        None
285    }
286}
287
288impl URI {
289    /// Adds a scheme to the filter list.
290    ///
291    /// When at least one scheme is added, only URIs with matching schemes will be found.
292    /// Scheme matching is case-insensitive.
293    ///
294    /// # Example
295    ///
296    /// ```
297    /// use squeeze::{uri::URI, Finder};
298    ///
299    /// let mut finder = URI::default();
300    /// finder.add_scheme("https");
301    /// finder.add_scheme("mailto");
302    ///
303    /// // Will match https:// and mailto: URIs, but not http:// or ftp://
304    /// ```
305    pub fn add_scheme(&mut self, s: &str) {
306        let lower = s.to_lowercase();
307        if let Some(&last) = lower.as_bytes().last() {
308            self.scheme_ends[usize::from(last >> 6)] |= 1 << (last & 63);
309        }
310        if let Err(pos) = self.schemes.binary_search(&lower) {
311            self.schemes.insert(pos, lower);
312        }
313    }
314
315    fn try_at_colon(&self, input: &[u8], colon_idx: usize) -> Option<Range<usize>> {
316        if input[colon_idx] != b':' {
317            return None;
318        }
319
320        // Lax mode without an allowlist: anything but `//` after the colon
321        // requires a registered scheme, whose last two bytes sit right
322        // before the colon. Checked first, before any walk.
323        if !self.strict
324            && self.schemes.is_empty()
325            && input.get(colon_idx + 1) != Some(&b'/')
326            && colon_idx >= 1
327        {
328            let prev1 = input[colon_idx - 1];
329            let prev2 = if colon_idx >= 2 {
330                input[colon_idx - 2]
331            } else {
332                b' '
333            };
334            if !can_end_registered_scheme(prev2, prev1) {
335                return None;
336            }
337        }
338
339        let scheme_idx = self.rlook_scheme(&input[..colon_idx])?;
340        let scheme = std::str::from_utf8(&input[scheme_idx..colon_idx]).ok()?;
341        // Check the scheme allowlist before parsing the rest of the URI.
342        if !self.schemes.is_empty() && !self.schemes.iter().any(|s| s.eq_ignore_ascii_case(scheme))
343        {
344            return None;
345        }
346        // Lax mode without an allowlist: a scheme not followed by `//` must
347        // be registered. Decided here rather than after the parse, so
348        // `localhost:8080` costs one registry lookup.
349        if !self.strict
350            && self.schemes.is_empty()
351            && !input[colon_idx + 1..].starts_with(b"//")
352            && !is_registered_scheme(scheme)
353        {
354            return None;
355        }
356        let scheme_config = scheme_config(scheme);
357
358        // "Self::Error", "io::Result", "db8::/32": in lax mode a second colon
359        // marks a code path or an IPv6 tail, not a URI.
360        if !self.strict && input.get(colon_idx + 1) == Some(&b':') {
361            return None;
362        }
363
364        let mut idx = colon_idx + 1;
365        let hier_len = self.look_hier_part(&input[idx..], scheme_config)?;
366        idx += hier_len;
367        idx += self.look_question_mark_query(&input[idx..]).unwrap_or(0);
368        idx += self.look_sharp_fragment(&input[idx..]).unwrap_or(0);
369
370        if !self.strict {
371            idx = Self::trim_lax(input, colon_idx, idx);
372            let tail = &input[colon_idx + 1..idx];
373            // A bare "scheme:" or a punctuation-only tail is prose ("TODO:",
374            // "Note: …").
375            if tail
376                .iter()
377                .all(|&b| matches!(b, b'.' | b',' | b';' | b':' | b'!' | b'?' | b'#'))
378            {
379                return None;
380            }
381            // An empty hier-part ("magnet:?xt=…") is only trusted at the start
382            // of the input or after prose (whitespace, quotes, opening
383            // brackets…). Glued to URI innards ("?q=x:?y", "a:=x:#y", …) it is
384            // junk — and accepting it would desynchronize trigger dispatch
385            // from slice-based find() restarts.
386            if hier_len == 0 && scheme_idx > 0 && !Self::is_prose_delimiter(input[scheme_idx - 1]) {
387                return None;
388            }
389            // Without "//", "key:value", "main.rs:42" and "${VAR:-x}" parse
390            // as opaque URIs; only trust schemes the user asked for or IANA
391            // registered.
392            if self.schemes.is_empty() && !tail.starts_with(b"//") && !is_registered_scheme(scheme)
393            {
394                return None;
395            }
396        }
397
398        Some(scheme_idx..idx)
399    }
400
401    /// Lax-mode cleanup of a parsed candidate: cut at the first `)` that has
402    /// no matching `(` inside the candidate, then drop trailing punctuation
403    /// that is almost certainly prose. Never trims past the scheme colon.
404    fn trim_lax(input: &[u8], colon_idx: usize, mut end: usize) -> usize {
405        let body_start = colon_idx + 1;
406
407        // Only a body holding ")" needs its parentheses balanced.
408        if memchr::memchr(b')', &input[body_start..end]).is_some() {
409            let mut depth = 0usize;
410            for (i, &b) in input[body_start..end].iter().enumerate() {
411                match b {
412                    b'(' => depth += 1,
413                    b')' => {
414                        if depth == 0 {
415                            end = body_start + i;
416                            break;
417                        }
418                        depth -= 1;
419                    }
420                    _ => {}
421                }
422            }
423        }
424
425        while end > body_start && matches!(input[end - 1], b'.' | b',' | b';' | b':' | b'!' | b'?')
426        {
427            end -= 1;
428        }
429
430        end
431    }
432
433    // Bytes that cannot appear inside a lax URI match; a bare "scheme:" is
434    // only accepted right after one of these (or at the start of the input).
435    #[inline]
436    fn is_prose_delimiter(b: u8) -> bool {
437        b <= b' '
438            || b >= 0x80
439            || matches!(
440                b,
441                b'"' | b'\'' | b'<' | b'>' | b'\\' | b'^' | b'`' | b'{' | b'|' | b'}' | b'(' | b'['
442            )
443    }
444
445    // ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
446    fn rlook_scheme(&self, input: &[u8]) -> Option<usize> {
447        let start = input.len().saturating_sub(MAX_SCHEME_LEN);
448        let mut idx = None;
449        let mut run_start = input.len();
450        for (i, &c) in input[start..].iter().enumerate().rev() {
451            if Self::is_alpha(c) {
452                idx = Some(start + i);
453                run_start = start + i;
454            } else if Self::is_digit(c) || c == b'+' || c == b'-' || c == b'.' {
455                run_start = start + i;
456            } else {
457                break;
458            }
459        }
460        // A run glued to leading digits ("2024-01-15T10:") is a number, not a
461        // scheme. Runs starting with "." / "+" / "-" still anchor on their
462        // first ALPHA ("(http://x", "-http://x").
463        if run_start < input.len() && Self::is_digit(input[run_start]) {
464            return None;
465        }
466        idx
467    }
468
469    // hier-part = "//" authority path-abempty
470    //           / path-absolute
471    //           / path-rootless
472    //           / path-empty
473    fn look_hier_part(&self, input: &[u8], sc: SchemeConfig) -> Option<usize> {
474        // "//" authority path-abempty
475        // When the input starts with "//" this is the only valid production:
476        // path-absolute requires a non-empty first segment, so a failed
477        // authority must reject the candidate instead of degrading to a
478        // "scheme:/" stub.
479        if let Some(idx) = self.look_slash_slash(input) {
480            let idx = idx + self.look_authority(&input[idx..], sc)?;
481            return Some(idx + self.look_path_abempty(&input[idx..]));
482        }
483
484        // Some schemes disallow empty hosts
485        if sc.has(DISALLOW_EMPTY_HOST) {
486            return None;
487        }
488
489        // "/" [ segment-nz path-abempty ]
490        if let Some(idx) = self.look_slash(input).map(|idx| {
491            idx + self
492                .look_segment_nz(&input[idx..])
493                .map(|i| i + self.look_path_abempty(&input[idx + i..]))
494                .unwrap_or(0)
495        }) {
496            return Some(idx);
497        }
498
499        // segment-nz path-abempty
500        if let Some(idx) = self
501            .look_segment_nz(input)
502            .map(|idx| idx + self.look_path_abempty(&input[idx..]))
503        {
504            return Some(idx);
505        }
506
507        // 0<pchar>
508        Some(0)
509    }
510
511    // [ userinfo "@" ] host [ ":" port ]
512    fn look_authority(&self, input: &[u8], sc: SchemeConfig) -> Option<usize> {
513        let mut idx = 0;
514        idx += self.look_userinfo_at(&input[idx..]).unwrap_or(0);
515        idx += self
516            .look_host(&input[idx..])
517            .filter(|&i| !(i == 0 && sc.has(DISALLOW_EMPTY_HOST)))?;
518        if let Some(i) = self.look_colon(&input[idx..]) {
519            idx += i;
520            let port_len = self.look_port(&input[idx..]);
521            idx += port_len;
522            // A port glued to further scheme-run characters ("…:80foo") would
523            // end the match mid-token; reject the candidate instead so the
524            // Scanner and slice-based find() restarts stay in sync.
525            let glued = input.get(idx).is_some_and(|&b| {
526                b.is_ascii_alphanumeric() || b == b'+' || b == b'-' || (b == b'.' && port_len > 0)
527            });
528            if glued {
529                return None;
530            }
531        }
532        Some(idx)
533    }
534
535    // *( "/" segment )
536    fn look_path_abempty(&self, input: &[u8]) -> usize {
537        let mut idx = 0;
538        while idx < input.len() {
539            idx += match self
540                .look_slash(&input[idx..])
541                .map(|i| i + self.look_segment(&input[idx + i..]))
542            {
543                Some(n) => n,
544                None => break,
545            };
546        }
547        idx
548    }
549
550    // *pchar
551    fn look_segment(&self, input: &[u8]) -> usize {
552        self.look_run(input, PCHAR)
553    }
554
555    /// Length of the run of bytes in the body class `class` or
556    /// percent-encoded triples starting `input`.
557    #[inline]
558    fn look_run(&self, input: &[u8], class: u8) -> usize {
559        let table = self.body_classes();
560        let mut idx = 0;
561        while idx < input.len() {
562            let b = input[idx];
563            if table[b as usize] & class != 0 {
564                idx += 1;
565            } else if b == b'%' {
566                match self.look_pct_encoded(&input[idx..]) {
567                    Some(n) => idx += n,
568                    None => break,
569                }
570            } else {
571                break;
572            }
573        }
574        idx
575    }
576
577    #[inline]
578    fn body_classes(&self) -> &'static [u8; 256] {
579        if self.strict {
580            &BODY_CLASSES_STRICT
581        } else {
582            &BODY_CLASSES_LAX
583        }
584    }
585
586    // 1*pchar
587    fn look_segment_nz(&self, input: &[u8]) -> Option<usize> {
588        match self.look_segment(input) {
589            0 => None,
590            n => Some(n),
591        }
592    }
593
594    // userinfo "@"
595    //
596    // Walks the userinfo run instead of searching for "@" first, so a host
597    // without userinfo costs its own length rather than a 256-byte search.
598    fn look_userinfo_at(&self, input: &[u8]) -> Option<usize> {
599        let table = self.body_classes();
600        let mut idx = 0;
601        while idx < input.len() && idx < 256 {
602            let b = input[idx];
603            if b == b'@' {
604                return Some(idx + 1);
605            }
606            if table[b as usize] & USERINFO != 0 {
607                idx += 1;
608            } else if b == b'%' {
609                idx += self.look_pct_encoded(&input[idx..])?;
610            } else {
611                return None;
612            }
613        }
614        None
615    }
616
617    // IP-literal / IPv4address / reg-name
618    fn look_host(&self, input: &[u8]) -> Option<usize> {
619        if let Some(idx) = self.look_ip_literal(input) {
620            // "[::1]x" — an IP-literal glued to further token characters is
621            // invalid (only ":" port, path, query, or fragment may follow
622            // "]") and would end the match mid-token: reject the candidate.
623            if input
624                .get(idx)
625                .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'.' || b == b'+' || b == b'-')
626            {
627                return None;
628            }
629            return Some(idx);
630        }
631        if let Some(idx) = self.look_ipv4_address(input) {
632            match input.get(idx) {
633                // The dotted quad is only a prefix of a longer reg-name
634                // ("10.0.0.1.nip.io", "1.2.3.4.5"): parse it as a hostname.
635                Some(&b) if b == b'.' || b == b'-' || b == b'_' || b.is_ascii_alphanumeric() => {}
636                // Host glued to non-ASCII is IRI garbage: reject.
637                Some(&b) if b >= 0x80 => return None,
638                _ => return Some(idx),
639            }
640        }
641        self.look_hostname(input)
642    }
643
644    // "[" ( IPv6address / IPv6addrz / IPvFuture ) "]"
645    fn look_ip_literal(&self, input: &[u8]) -> Option<usize> {
646        let mut idx = 0;
647        idx += self.look_left_bracket(&input[idx..])?;
648        let right_bracket_index = input[idx..].iter().take(64).position(|&b| b == b']')?;
649        if right_bracket_index > 0 {
650            let end = idx + right_bracket_index;
651            let slice = &input[idx..end];
652            if self.is_ipv6address(slice) || self.is_ipv6addrz(slice) || self.is_ipvfuture(slice) {
653                return Some(end + 1);
654            }
655        }
656        None
657    }
658
659    // IPv6addrz = IPv6address "%25" ZoneID (RFC 6874). Leniently, a bare "%"
660    // separator is also accepted ("[fe80::1%eth0]" as produced by OS tools).
661    fn is_ipv6addrz(&self, input: &[u8]) -> bool {
662        let Some(pct) = input.iter().position(|&b| b == b'%') else {
663            return false;
664        };
665        if !self.is_ipv6address(&input[..pct]) {
666            return false;
667        }
668        let mut zone = &input[pct + 1..];
669        if zone.starts_with(b"25") && zone.len() > 2 {
670            zone = &zone[2..]; // RFC 6874 "%25" delimiter
671        }
672        !zone.is_empty() && zone.iter().all(|&b| Self::is_unreserved(b))
673    }
674
675    // Delegates to the std-exact validator in `crate::ipv6`, which handles
676    // embedded IPv4 tails ("::ffff:127.0.0.1", "64:ff9b::192.0.2.33").
677    fn is_ipv6address(&self, input: &[u8]) -> bool {
678        crate::ipv6::is_valid_ipv6(input)
679    }
680
681    // "v" 1*HEXDIG "." 1*( unreserved / sub-delims / ":" )
682    fn is_ipvfuture(&self, input: &[u8]) -> bool {
683        if input.is_empty() || input[0] != b'v' {
684            return false;
685        }
686
687        let mut idx = 1;
688
689        // 1*HEXDIG
690        let hexdig_start = idx;
691        while idx < input.len() && Self::is_hexdig(input[idx]) {
692            idx += 1;
693        }
694        if idx == hexdig_start {
695            return false; // Need at least one HEXDIG
696        }
697
698        // "."
699        if idx >= input.len() || input[idx] != b'.' {
700            return false;
701        }
702        idx += 1;
703
704        // 1*( unreserved / sub-delims / ":" )
705        let char_start = idx;
706        while idx < input.len() {
707            let c = input[idx];
708            if Self::is_unreserved(c) || Self::is_sub_delim_strict(c) || c == b':' {
709                idx += 1;
710            } else {
711                break;
712            }
713        }
714        if idx == char_start {
715            return false; // Need at least one character after the dot
716        }
717
718        idx == input.len()
719    }
720
721    #[inline]
722    fn is_sub_delim_strict(c: u8) -> bool {
723        SUB_DELIMS_STRICT[c as usize]
724    }
725
726    // dec-octet "." dec-octet "." dec-octet "." dec-octet
727    fn look_ipv4_address(&self, input: &[u8]) -> Option<usize> {
728        let mut idx = 0;
729        idx += self.look_dec_octet(&input[idx..])?;
730        idx += self.look_period(&input[idx..])?;
731        idx += self.look_dec_octet(&input[idx..])?;
732        idx += self.look_period(&input[idx..])?;
733        idx += self.look_dec_octet(&input[idx..])?;
734        idx += self.look_period(&input[idx..])?;
735        idx += self.look_dec_octet(&input[idx..])?;
736        Some(idx)
737    }
738
739    // dec-octet     = DIGIT                 ; 0-9
740    //               / %x31-39 DIGIT         ; 10-99
741    //               / "1" 2DIGIT            ; 100-199
742    //               / "2" %x30-34 DIGIT     ; 200-249
743    //               / "25" %x30-35          ; 250-255
744    fn look_dec_octet(&self, input: &[u8]) -> Option<usize> {
745        if input.len() >= 3
746            && input[0] == b'2'
747            && input[1] == b'5'
748            && Self::is_digit_0_to_5(input[2])
749        {
750            return Some(3);
751        }
752
753        if input.len() >= 3
754            && input[0] == b'2'
755            && Self::is_digit_0_to_4(input[1])
756            && Self::is_digit(input[2])
757        {
758            return Some(3);
759        }
760
761        if input.len() >= 3
762            && input[0] == b'1'
763            && Self::is_digit(input[1])
764            && Self::is_digit(input[2])
765        {
766            return Some(3);
767        }
768
769        if input.len() >= 2 && Self::is_digit_1_to_9(input[0]) && Self::is_digit(input[1]) {
770            return Some(2);
771        }
772
773        if !input.is_empty() && Self::is_digit(input[0]) {
774            return Some(1);
775        }
776
777        None
778    }
779
780    // https://en.wikipedia.org/wiki/Hostname#Restrictions_on_valid_hostnames
781    //
782    // Rejecting (rather than truncating) oversized labels/hostnames keeps
783    // matches from ending mid-token, which would desynchronize the Scanner
784    // from slice-based find() restarts.
785    fn look_hostname(&self, input: &[u8]) -> Option<usize> {
786        const MAX_LABEL: usize = 63; // RFC 1035
787        const MAX_HOSTNAME: usize = 253;
788
789        let mut idx = 0;
790        loop {
791            let run = Self::label_run_len(&input[idx..]);
792            if run == 0 {
793                break; // only reachable at idx == 0 (possibly-empty host)
794            }
795            if run > MAX_LABEL {
796                return None;
797            }
798            idx += run;
799            // Consume a "." only when another label follows (a trailing dot is
800            // sentence punctuation, not part of the host).
801            if input.get(idx) == Some(&b'.') && Self::label_run_len(&input[idx + 1..]) > 0 {
802                idx += 1;
803                continue;
804            }
805            break;
806        }
807        if idx > MAX_HOSTNAME {
808            return None;
809        }
810        // A host that stops right before a non-ASCII byte (also right after an
811        // unconsumed dot) is the ASCII prefix of an IRI: reject the candidate.
812        match input.get(idx) {
813            Some(&b) if b >= 0x80 => return None,
814            Some(&b'.') if input.get(idx + 1).is_some_and(|&b| b >= 0x80) => return None,
815            _ => {}
816        }
817        Some(idx)
818    }
819
820    // Length of the maximal label-character run: [A-Za-z0-9_] first, then also
821    // "-". Returns 0 when no label starts here.
822    fn label_run_len(input: &[u8]) -> usize {
823        let starts = input
824            .first()
825            .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_');
826        if !starts {
827            return 0;
828        }
829        // Both modes share the label class.
830        let table = &BODY_CLASSES_LAX;
831        let mut len = 1;
832        while len < input.len() && table[input[len] as usize] & LABEL != 0 {
833            len += 1;
834        }
835        len
836    }
837
838    // *DIGIT
839    fn look_port(&self, input: &[u8]) -> usize {
840        input.iter().take_while(|&&c| Self::is_digit(c)).count()
841    }
842
843    fn look_question_mark_query(&self, input: &[u8]) -> Option<usize> {
844        let mut idx = 0;
845        idx += self.look_question_mark(&input[idx..])?;
846        idx += self.look_query(&input[idx..]);
847        Some(idx)
848    }
849
850    // *( pchar / "/" / "?" )
851    fn look_query(&self, input: &[u8]) -> usize {
852        self.look_run(input, QUERY)
853    }
854
855    fn look_sharp_fragment(&self, input: &[u8]) -> Option<usize> {
856        let mut idx = 0;
857        idx += self.look_sharp(&input[idx..])?;
858        idx += self.look_fragment(&input[idx..]);
859        Some(idx)
860    }
861
862    // *( pchar / "/" / "?" )
863    fn look_fragment(&self, input: &[u8]) -> usize {
864        self.look_run(input, QUERY)
865    }
866
867    // "%" HEXDIG HEXDIG
868    #[inline]
869    fn look_pct_encoded(&self, input: &[u8]) -> Option<usize> {
870        if input.len() >= 3
871            && input[0] == b'%'
872            && Self::is_hexdig(input[1])
873            && Self::is_hexdig(input[2])
874        {
875            Some(3)
876        } else {
877            None
878        }
879    }
880
881    #[inline]
882    fn look_period(&self, input: &[u8]) -> Option<usize> {
883        if !input.is_empty() && input[0] == b'.' {
884            Some(1)
885        } else {
886            None
887        }
888    }
889
890    #[inline]
891    fn look_left_bracket(&self, input: &[u8]) -> Option<usize> {
892        if !input.is_empty() && input[0] == b'[' {
893            Some(1)
894        } else {
895            None
896        }
897    }
898
899    #[inline]
900    fn look_colon(&self, input: &[u8]) -> Option<usize> {
901        if !input.is_empty() && input[0] == b':' {
902            Some(1)
903        } else {
904            None
905        }
906    }
907
908    #[inline]
909    fn look_question_mark(&self, input: &[u8]) -> Option<usize> {
910        if !input.is_empty() && input[0] == b'?' {
911            Some(1)
912        } else {
913            None
914        }
915    }
916
917    #[inline]
918    fn look_sharp(&self, input: &[u8]) -> Option<usize> {
919        if !input.is_empty() && input[0] == b'#' {
920            Some(1)
921        } else {
922            None
923        }
924    }
925
926    #[inline]
927    fn look_slash(&self, input: &[u8]) -> Option<usize> {
928        if !input.is_empty() && input[0] == b'/' {
929            Some(1)
930        } else {
931            None
932        }
933    }
934
935    #[inline]
936    fn look_slash_slash(&self, input: &[u8]) -> Option<usize> {
937        if input.len() >= 2 && input[0] == b'/' && input[1] == b'/' {
938            Some(2)
939        } else {
940            None
941        }
942    }
943
944    // ALPHA / DIGIT / "-" / "." / "_" / "~"
945    #[inline]
946    fn is_unreserved(c: u8) -> bool {
947        c.is_ascii_alphanumeric() || c == b'-' || c == b'.' || c == b'_' || c == b'~'
948    }
949
950    #[inline]
951    fn is_alpha(c: u8) -> bool {
952        c.is_ascii_alphabetic()
953    }
954
955    #[inline]
956    fn is_digit(c: u8) -> bool {
957        c.is_ascii_digit()
958    }
959
960    #[inline]
961    fn is_digit_1_to_9(c: u8) -> bool {
962        (b'1'..=b'9').contains(&c)
963    }
964
965    #[inline]
966    fn is_digit_0_to_4(c: u8) -> bool {
967        (b'0'..=b'4').contains(&c)
968    }
969
970    #[inline]
971    fn is_digit_0_to_5(c: u8) -> bool {
972        (b'0'..=b'5').contains(&c)
973    }
974
975    #[inline]
976    fn is_hexdig(c: u8) -> bool {
977        c.is_ascii_hexdigit()
978    }
979}
980
981#[cfg(test)]
982#[allow(clippy::bool_assert_comparison)]
983#[allow(clippy::useless_vec)]
984#[allow(clippy::field_reassign_with_default)]
985mod tests {
986    use super::*;
987
988    #[test]
989    fn is_ipv6address_should_identify_valid_ipv6s() {
990        let finder = URI::default();
991        for input in [
992            "::",
993            "::1",
994            "1::",
995            "1:2:3:4:5:6:7:8",
996            "1:2:3:4:5:6::7",
997            "1:2:3:4:5:6:127.0.0.1",
998            "1::127.0.0.1",
999        ] {
1000            assert_eq!(true, finder.is_ipv6address(input.as_bytes()), "{}", input);
1001        }
1002    }
1003
1004    #[test]
1005    fn is_ipv6address_should_identify_invalid_ipv6s() {
1006        let finder = URI::default();
1007        for input in vec![
1008            " ",
1009            " ::",
1010            ":: ",
1011            " :: ",
1012            ":::",
1013            "::1::",
1014            ":1:",
1015            "1:2:3:4:5:6:7:8:9",
1016            "1:2:3:4:5:6:7:127.0.0.1",
1017            "1:2:3:4:5:6::7:8",
1018            "1:2:3:4:5:6::127.0.0.1",
1019            "1:127.0.0.1::",
1020        ] {
1021            assert_eq!(false, finder.is_ipv6address(input.as_bytes()), "{}", input);
1022        }
1023    }
1024
1025    #[test]
1026    fn look_path_abempty_should_mirror_the_len_of_valid_inputs() {
1027        let finder = URI::default();
1028        for input in vec![
1029            "",
1030            "/",
1031            "//",
1032            "///",
1033            "/foo/bar",
1034            "/rfc/rfc1808.txt",
1035            "/with/trailing/",
1036        ] {
1037            assert_eq!(
1038                input.len(),
1039                finder.look_path_abempty(input.as_bytes()),
1040                "{}",
1041                input
1042            );
1043        }
1044    }
1045
1046    #[test]
1047    fn look_path_abempty_should_skip_invalid_inputs() {
1048        let finder = URI::default();
1049        for input in vec!["foobar"] {
1050            assert_eq!(0, finder.look_path_abempty(input.as_bytes()), "{}", input);
1051        }
1052    }
1053
1054    #[test]
1055    fn it_should_mirror_valid_uris() {
1056        let finder = URI::default();
1057        for input in vec![
1058            // basic
1059            "http://localhost",
1060            // userinfo
1061            "http://foobar:@localhost",
1062            "http://foobar:baz@localhost",
1063            // port
1064            "http://foobar:@localhost:8080",
1065            // path
1066            "http://localhost/lorem",
1067            // query
1068            "http://foobar:@localhost:8080?a=b",
1069            // fragment
1070            "http://foobar:@localhost:8080#",
1071            "http://foobar:@localhost:8080?#",
1072            "http://foobar:@localhost:8080?a=b#",
1073            "http://foobar:@localhost:8080?a=b#c=d",
1074            // meh
1075            "http://:@localhost:/?#",
1076            // ipv4
1077            "http://127.0.0.0",
1078            "http://127.0.0.10",
1079            "http://127.0.0.100",
1080            "http://127.0.0.200",
1081            "http://127.0.0.250",
1082            "http://192.0.2.235",
1083            // ipv6
1084            "http://[::]",
1085            "http://[::1]",
1086            "http://[2001:db8::1]",
1087            "http://[2001:0db8::0001]",
1088            "http://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]",
1089            "http://[::ffff:192.0.2.128]",
1090            "http://[::ffff:c000:0280]",
1091            // rfc examples
1092            "file:///etc/hosts",
1093            "http://localhost/",
1094            "mailto:fred@example.com",
1095            "foo://info.example.com?fred",
1096            "ftp://ftp.is.co.za/rfc/rfc1808.txt",
1097            "http://www.ietf.org/rfc/rfc2396.txt",
1098            "ldap://[2001:db8::7]/c=GB?objectClass?one",
1099            "mailto:John.Doe@example.com",
1100            "news:comp.infosystems.www.servers.unix",
1101            "tel:+1-816-555-1212",
1102            "telnet://192.0.2.16:80/",
1103            "urn:oasis:names:specification:docbook:dtd:xml:4.1.2",
1104        ] {
1105            for i in vec![
1106                input.to_owned(),
1107                format!(" {} ", input),
1108                format!("<{}>", input),
1109                format!("[{}]", input),
1110                format!("<a href=\"{}\">link</a>", input),
1111                format!("{{{}}}", input),
1112                format!("\"{}\"", input),
1113                format!("[link]({})", input),
1114                format!("'{}'", input),
1115            ] {
1116                assert_eq!(Some(input), finder.find(&i).map(|r| &i[r]), "{}", input);
1117            }
1118        }
1119    }
1120
1121    #[test]
1122    fn it_should_properly_behave_in_strict_mode() {
1123        let mut finder = URI::default();
1124        finder.strict = true;
1125        for &input in &[
1126            "http://localhost/)",
1127            "http://localhost/'",
1128            // Trailing empty port / empty query / "!" are trimmed in lax mode
1129            // but strictly valid per RFC 3986.
1130            "http://foobar:@localhost:",
1131            "http://foobar:@localhost:8080?",
1132            "data:,Hello%2C%20World!",
1133        ] {
1134            assert_eq!(Some(input), finder.find(input).map(|r| &input[r]));
1135        }
1136    }
1137
1138    #[test]
1139    fn it_should_ignore_invalid_uris() {
1140        let finder = URI::default();
1141        for input in vec![
1142            // some protocols require a host
1143            "ftp:///test",
1144            "http:///test",
1145            "https:///test",
1146        ] {
1147            assert_eq!(None, finder.find(input), "{}", input);
1148        }
1149    }
1150
1151    #[test]
1152    fn is_ipvfuture_should_identify_valid_ipvfutures() {
1153        let finder = URI::default();
1154        for input in vec![
1155            "v1.test",
1156            "vFF.a",
1157            "v1.fe80",
1158            "v1.a:b:c",
1159            "vABCDEF.test",
1160            "v1.unreserved-chars_here~",
1161            "v1.sub!delims$and&more",
1162        ] {
1163            assert_eq!(true, finder.is_ipvfuture(input.as_bytes()), "{}", input);
1164        }
1165    }
1166
1167    #[test]
1168    fn is_ipvfuture_should_identify_invalid_ipvfutures() {
1169        let finder = URI::default();
1170        for input in vec![
1171            "", "v", "v1", "v1.", "v.", "v.test", "1.test",
1172            "vGG.test", // G is not a valid hex digit
1173        ] {
1174            assert_eq!(false, finder.is_ipvfuture(input.as_bytes()), "{}", input);
1175        }
1176    }
1177
1178    #[test]
1179    fn it_should_mirror_valid_ipvfuture_uris() {
1180        let finder = URI::default();
1181        for input in vec![
1182            "http://[v1.test]/path",
1183            "http://[vFF.fe80]/",
1184            "http://[v1.a:b:c]",
1185            "http://[v1.test]:8080/path",
1186        ] {
1187            assert_eq!(
1188                Some(input),
1189                finder.find(input).map(|r| &input[r]),
1190                "{}",
1191                input
1192            );
1193        }
1194    }
1195
1196    #[test]
1197    fn it_should_mirror_data_uris() {
1198        let finder = URI::default();
1199        for input in vec![
1200            "data:text/plain;base64,SGVsbG8=",
1201            "data:image/png;base64,iVBORw0KGgo=",
1202            "data:,Hello%2C%20World",
1203            "data:text/html,%3Ch1%3EHello%3C%2Fh1%3E",
1204        ] {
1205            assert_eq!(
1206                Some(input),
1207                finder.find(input).map(|r| &input[r]),
1208                "{}",
1209                input
1210            );
1211        }
1212    }
1213
1214    #[test]
1215    fn it_should_mirror_mailto_uris() {
1216        let finder = URI::default();
1217        for input in vec![
1218            "mailto:user@example.com",
1219            "mailto:user@example.com?subject=Hello&body=World",
1220            "mailto:user@example.com,other@example.com",
1221        ] {
1222            assert_eq!(
1223                Some(input),
1224                finder.find(input).map(|r| &input[r]),
1225                "{}",
1226                input
1227            );
1228        }
1229    }
1230
1231    #[test]
1232    fn it_should_mirror_tel_uris() {
1233        let finder = URI::default();
1234        for input in vec![
1235            "tel:+1-816-555-1212",
1236            "tel:+1-816-555-1212;ext=1234",
1237            "tel:911",
1238        ] {
1239            assert_eq!(
1240                Some(input),
1241                finder.find(input).map(|r| &input[r]),
1242                "{}",
1243                input
1244            );
1245        }
1246    }
1247
1248    #[test]
1249    fn it_should_mirror_uris_with_percent_encoding() {
1250        let finder = URI::default();
1251        for input in vec![
1252            "http://example.com/path%20with%20spaces",
1253            "http://example.com/path?q=%E2%9C%93",
1254            "http://user%40name:pass%23word@example.com",
1255        ] {
1256            assert_eq!(
1257                Some(input),
1258                finder.find(input).map(|r| &input[r]),
1259                "{}",
1260                input
1261            );
1262        }
1263    }
1264
1265    #[test]
1266    fn it_should_mirror_uris_with_fragments() {
1267        let finder = URI::default();
1268        for input in vec![
1269            "http://example.com#section",
1270            "http://example.com/page#",
1271            "http://example.com?query#fragment",
1272        ] {
1273            assert_eq!(
1274                Some(input),
1275                finder.find(input).map(|r| &input[r]),
1276                "{}",
1277                input
1278            );
1279        }
1280    }
1281
1282    #[test]
1283    fn it_should_mirror_uris_with_empty_paths() {
1284        let finder = URI::default();
1285        for input in vec!["http://example.com?query", "http://example.com#fragment"] {
1286            assert_eq!(
1287                Some(input),
1288                finder.find(input).map(|r| &input[r]),
1289                "{}",
1290                input
1291            );
1292        }
1293    }
1294
1295    #[test]
1296    fn it_should_mirror_uris_with_multiple_slashes() {
1297        let finder = URI::default();
1298        for input in vec![
1299            "http://example.com//path",
1300            "http://example.com//path///to////resource",
1301        ] {
1302            assert_eq!(
1303                Some(input),
1304                finder.find(input).map(|r| &input[r]),
1305                "{}",
1306                input
1307            );
1308        }
1309    }
1310
1311    #[test]
1312    fn it_should_mirror_ipv4_mapped_ipv6_uris() {
1313        let finder = URI::default();
1314        for input in vec![
1315            "http://[::ffff:192.168.1.1]",
1316            "http://[::ffff:127.0.0.1]:8080/path",
1317        ] {
1318            assert_eq!(
1319                Some(input),
1320                finder.find(input).map(|r| &input[r]),
1321                "{}",
1322                input
1323            );
1324        }
1325    }
1326
1327    #[test]
1328    fn it_should_mirror_punycode_domains() {
1329        let finder = URI::default();
1330        for input in vec!["http://xn--n3h.com", "http://xn--nxasmq5b.com"] {
1331            assert_eq!(
1332                Some(input),
1333                finder.find(input).map(|r| &input[r]),
1334                "{}",
1335                input
1336            );
1337        }
1338    }
1339
1340    #[test]
1341    fn it_should_mirror_hostnames_with_underscores() {
1342        let finder = URI::default();
1343        for input in vec!["http://my_server.local", "http://_dmarc.example.com"] {
1344            assert_eq!(
1345                Some(input),
1346                finder.find(input).map(|r| &input[r]),
1347                "{}",
1348                input
1349            );
1350        }
1351    }
1352
1353    #[test]
1354    fn it_should_mirror_single_label_hostnames() {
1355        let finder = URI::default();
1356        for input in vec![
1357            "http://localhost",
1358            "http://intranet",
1359            "http://localhost:8080",
1360        ] {
1361            assert_eq!(
1362                Some(input),
1363                finder.find(input).map(|r| &input[r]),
1364                "{}",
1365                input
1366            );
1367        }
1368    }
1369
1370    // ============================================================================
1371    // Edge case tests
1372    // ============================================================================
1373
1374    #[test]
1375    fn it_should_handle_empty_string() {
1376        let finder = URI::default();
1377        assert_eq!(None, finder.find(""));
1378    }
1379
1380    #[test]
1381    fn it_should_handle_string_without_uris() {
1382        let finder = URI::default();
1383        // Note: URIs require scheme:... where scheme starts with ALPHA
1384        // So inputs without colons or with only non-alpha before colon won't match
1385        for input in vec![
1386            "hello world",
1387            "no urls here",
1388            "just some text",
1389            "12345",      // no colon
1390            "   ",        // no colon
1391            "123:456",    // scheme must start with alpha
1392            ":no-scheme", // colon at start
1393        ] {
1394            assert_eq!(None, finder.find(input), "{}", input);
1395        }
1396    }
1397
1398    #[test]
1399    fn it_should_handle_very_long_uris() {
1400        let finder = URI::default();
1401
1402        // Long path
1403        let long_path = format!("http://example.com/{}", "a".repeat(2000));
1404        assert_eq!(
1405            Some(long_path.as_str()),
1406            finder.find(&long_path).map(|r| &long_path[r])
1407        );
1408
1409        // Long query
1410        let long_query = format!("http://example.com?q={}", "b".repeat(2000));
1411        assert_eq!(
1412            Some(long_query.as_str()),
1413            finder.find(&long_query).map(|r| &long_query[r])
1414        );
1415
1416        // Long fragment
1417        let long_fragment = format!("http://example.com#{}", "c".repeat(2000));
1418        assert_eq!(
1419            Some(long_fragment.as_str()),
1420            finder.find(&long_fragment).map(|r| &long_fragment[r])
1421        );
1422    }
1423
1424    #[test]
1425    fn it_should_handle_malformed_percent_encoding() {
1426        let finder = URI::default();
1427
1428        // Valid percent encoding
1429        assert!(finder.find("http://example.com/%20").is_some());
1430        assert!(finder.find("http://example.com/%2F").is_some());
1431
1432        // Invalid hex digits - should stop before invalid encoding
1433        let input = "http://example.com/path%GG";
1434        let result = finder.find(input).map(|r| &input[r]);
1435        assert_eq!(Some("http://example.com/path"), result);
1436
1437        let input2 = "http://example.com/path%ZZ";
1438        let result2 = finder.find(input2).map(|r| &input2[r]);
1439        assert_eq!(Some("http://example.com/path"), result2);
1440    }
1441
1442    #[test]
1443    fn it_should_handle_incomplete_percent_encoding() {
1444        let finder = URI::default();
1445
1446        // Single % at end
1447        let input = "http://example.com/path%";
1448        let result = finder.find(input).map(|r| &input[r]);
1449        assert_eq!(Some("http://example.com/path"), result);
1450
1451        // % with only one hex digit
1452        let input2 = "http://example.com/path%2";
1453        let result2 = finder.find(input2).map(|r| &input2[r]);
1454        assert_eq!(Some("http://example.com/path"), result2);
1455    }
1456
1457    #[test]
1458    fn it_should_handle_unicode_in_hostnames() {
1459        let finder = URI::default();
1460
1461        // Punycode domains (IDN)
1462        for input in vec![
1463            "http://xn--n3h.com",        // Unicode domain encoded as punycode
1464            "http://xn--nxasmq5b.com",   // Another punycode domain
1465            "http://xn--80ak6aa92e.com", // Yet another
1466        ] {
1467            assert!(finder.find(input).is_some(), "{}", input);
1468        }
1469    }
1470
1471    #[test]
1472    fn it_should_handle_edge_case_ports() {
1473        let finder = URI::default();
1474
1475        // Empty port
1476        assert!(finder.find("http://example.com:").is_some());
1477
1478        // Port 0
1479        assert!(finder.find("http://example.com:0").is_some());
1480
1481        // Very large port number (technically invalid but should parse)
1482        assert!(finder.find("http://example.com:99999").is_some());
1483    }
1484
1485    #[test]
1486    fn it_should_handle_edge_case_userinfo() {
1487        let finder = URI::default();
1488
1489        // Empty userinfo with @
1490        assert!(finder.find("http://@example.com").is_some());
1491
1492        // Only username
1493        assert!(finder.find("http://user@example.com").is_some());
1494
1495        // Username and empty password
1496        assert!(finder.find("http://user:@example.com").is_some());
1497
1498        // Empty username and password
1499        assert!(finder.find("http://:pass@example.com").is_some());
1500
1501        // Special characters in userinfo (percent-encoded)
1502        assert!(finder.find("http://user%40name@example.com").is_some());
1503    }
1504
1505    #[test]
1506    fn it_should_handle_consecutive_special_chars() {
1507        let finder = URI::default();
1508
1509        // Multiple query params
1510        assert!(finder.find("http://example.com?a=1&b=2&c=3").is_some());
1511
1512        // Fragment parsing stops at second # (RFC 3986 fragment is *( pchar / "/" / "?" ))
1513        let input = "http://example.com#frag#ment";
1514        // The # character is not valid in a fragment, so parsing stops at #frag
1515        assert_eq!(
1516            Some("http://example.com#frag"),
1517            finder.find(input).map(|r| &input[r])
1518        );
1519
1520        // Query then fragment
1521        assert!(finder.find("http://example.com?query#fragment").is_some());
1522    }
1523
1524    #[test]
1525    fn it_should_handle_scheme_edge_cases() {
1526        let finder = URI::default();
1527
1528        // Scheme with numbers
1529        assert!(finder.find("h2c://example.com").is_some());
1530
1531        // Scheme with plus
1532        assert!(finder.find("coap+tcp://example.com").is_some());
1533
1534        // Scheme with dots
1535        assert!(finder.find("x.y.z://example.com").is_some());
1536
1537        // Scheme with hyphens
1538        assert!(finder.find("my-scheme://example.com").is_some());
1539
1540        // Single letter scheme
1541        assert!(finder.find("x://example.com").is_some());
1542    }
1543
1544    #[test]
1545    fn it_should_handle_ipv4_edge_cases() {
1546        let finder = URI::default();
1547
1548        // Boundary values
1549        assert!(finder.find("http://0.0.0.0").is_some());
1550        assert!(finder.find("http://255.255.255.255").is_some());
1551
1552        // Invalid IP (256) - should still find partial
1553        let input = "http://256.0.0.1";
1554        assert!(finder.find(input).is_some()); // Finds as hostname
1555
1556        // Leading zeros (valid but unusual)
1557        assert!(finder.find("http://192.168.001.001").is_some());
1558    }
1559
1560    #[test]
1561    fn it_should_handle_multiple_uris_in_text() {
1562        let finder = URI::default();
1563
1564        let text = "Visit http://first.com and https://second.com today";
1565        let result = finder.find(text);
1566        assert!(result.is_some());
1567        assert_eq!("http://first.com", &text[result.unwrap()]);
1568    }
1569
1570    #[test]
1571    fn it_should_handle_uri_at_string_boundaries() {
1572        let finder = URI::default();
1573
1574        // URI at start
1575        let input1 = "http://example.com is a URL";
1576        assert_eq!(
1577            Some("http://example.com"),
1578            finder.find(input1).map(|r| &input1[r])
1579        );
1580
1581        // URI at end
1582        let input2 = "Visit http://example.com";
1583        assert_eq!(
1584            Some("http://example.com"),
1585            finder.find(input2).map(|r| &input2[r])
1586        );
1587
1588        // URI alone
1589        let input3 = "http://example.com";
1590        assert_eq!(
1591            Some("http://example.com"),
1592            finder.find(input3).map(|r| &input3[r])
1593        );
1594    }
1595
1596    #[test]
1597    fn it_should_filter_by_scheme() {
1598        let mut finder = URI::default();
1599        finder.add_scheme("https");
1600
1601        // Should match https
1602        let input1 = "https://secure.com";
1603        assert_eq!(Some(input1), finder.find(input1).map(|r| &input1[r]));
1604
1605        // Should skip http, find nothing
1606        let input2 = "http://insecure.com";
1607        assert_eq!(None, finder.find(input2));
1608
1609        // Should skip http and find https
1610        let input3 = "http://first.com https://second.com";
1611        assert_eq!(
1612            Some("https://second.com"),
1613            finder.find(input3).map(|r| &input3[r])
1614        );
1615    }
1616
1617    #[test]
1618    fn it_should_handle_scheme_case_insensitivity() {
1619        let mut finder = URI::default();
1620        finder.add_scheme("HTTP");
1621
1622        // Should match lowercase http
1623        let input = "http://example.com";
1624        assert_eq!(Some(input), finder.find(input).map(|r| &input[r]));
1625
1626        // Should also match uppercase input schemes
1627        let input = "HTTP://example.com";
1628        assert_eq!(Some(input), finder.find(input).map(|r| &input[r]));
1629    }
1630
1631    #[test]
1632    fn uppercase_http_scheme_obeys_host_rules() {
1633        let finder = URI::default();
1634        assert_eq!(None, finder.find("HTTP:///missing-host"));
1635        assert_eq!(None, finder.find("HTTPS:///missing-host"));
1636    }
1637
1638    // --- Regression: sorted Vec scheme dedup ---
1639
1640    #[test]
1641    fn add_scheme_deduplicates() {
1642        let mut finder = URI::default();
1643        finder.add_scheme("http");
1644        finder.add_scheme("http");
1645        finder.add_scheme("HTTP");
1646        assert_eq!(finder.schemes.len(), 1);
1647    }
1648
1649    #[test]
1650    fn add_scheme_multiple_sorted() {
1651        let mut finder = URI::default();
1652        finder.add_scheme("https");
1653        finder.add_scheme("ftp");
1654        finder.add_scheme("http");
1655        assert_eq!(finder.schemes, vec!["ftp", "http", "https"]);
1656    }
1657
1658    // --- Regression: look_pchar branch order ---
1659
1660    #[test]
1661    fn look_pchar_unreserved_chars() {
1662        let finder = URI::default();
1663        for input in [
1664            "http://example.com/path-with-dashes_and_underscores.and.dots~tilde",
1665            "http://example.com/ABCDEFGHIJKLMNOPQRSTUVWXYZ",
1666            "http://example.com/abcdefghijklmnopqrstuvwxyz",
1667            "http://example.com/0123456789",
1668        ] {
1669            assert_eq!(
1670                Some(input),
1671                finder.find(input).map(|r| &input[r]),
1672                "{}",
1673                input
1674            );
1675        }
1676    }
1677
1678    #[test]
1679    fn look_pchar_pct_encoded() {
1680        let finder = URI::default();
1681        let input = "http://example.com/%20%2F%3A";
1682        assert_eq!(Some(input), finder.find(input).map(|r| &input[r]));
1683    }
1684
1685    #[test]
1686    fn look_pchar_sub_delims() {
1687        let finder = URI::default();
1688        let input = "http://example.com/path!$&(*+,;=";
1689        assert_eq!(Some(input), finder.find(input).map(|r| &input[r]));
1690    }
1691
1692    // --- Regression: rlook_scheme cap ---
1693
1694    #[test]
1695    fn rlook_scheme_very_long_prefix() {
1696        let finder = URI::default();
1697        let long_prefix = " ".repeat(1000);
1698        let input = format!("{}http://example.com", long_prefix);
1699        assert_eq!(
1700            Some("http://example.com"),
1701            finder.find(&input).map(|r| &input[r])
1702        );
1703    }
1704
1705    #[test]
1706    fn rlook_scheme_just_within_cap() {
1707        let finder = URI::default();
1708        let scheme = "a".repeat(30);
1709        let input = format!("{}://host", scheme);
1710        assert_eq!(Some(input.as_str()), finder.find(&input).map(|r| &input[r]));
1711    }
1712
1713    #[test]
1714    fn registered_schemes_should_fit_the_scheme_lookback() {
1715        for scheme in crate::iana::URI_SCHEMES.iter() {
1716            assert!(scheme.len() <= MAX_SCHEME_LEN, "{scheme}");
1717        }
1718    }
1719
1720    #[test]
1721    fn find_should_keep_long_registered_opaque_uris() {
1722        let finder = URI::default();
1723        let input = "open microsoft.windows.camera.multipicker:photo now";
1724        assert_eq!(
1725            Some("microsoft.windows.camera.multipicker:photo"),
1726            finder.find(input).map(|r| &input[r])
1727        );
1728    }
1729
1730    #[test]
1731    fn rlook_scheme_exceeds_cap() {
1732        let finder = URI::default();
1733        let scheme = "a".repeat(MAX_SCHEME_LEN + 8);
1734        let input = format!("{}://host", scheme);
1735        // Scheme longer than MAX_SCHEME_LEN gets truncated, but the URI
1736        // is still found with a shorter scheme prefix
1737        let result = finder.find(&input);
1738        assert!(result.is_some());
1739    }
1740
1741    // --- Regression: sub-delim lookup table ---
1742
1743    #[test]
1744    fn sub_delim_strict_vs_lax() {
1745        let strict = {
1746            let mut f = URI::default();
1747            f.strict = true;
1748            f
1749        };
1750        let lax = URI::default();
1751
1752        let with_apos = "http://example.com/it's";
1753        assert_eq!(
1754            Some("http://example.com/it's"),
1755            strict.find(with_apos).map(|r| &with_apos[r])
1756        );
1757        assert_eq!(
1758            Some("http://example.com/it"),
1759            lax.find(with_apos).map(|r| &with_apos[r])
1760        );
1761
1762        let with_paren = "http://example.com/path)";
1763        assert_eq!(
1764            Some("http://example.com/path)"),
1765            strict.find(with_paren).map(|r| &with_paren[r])
1766        );
1767        assert_eq!(
1768            Some("http://example.com/path"),
1769            lax.find(with_paren).map(|r| &with_paren[r])
1770        );
1771    }
1772}