Skip to main content

oxc_markdown_parser/inline/
gfm_autolink.rs

1//! GFM autolink literals (`www.…`, `http[s]://…`, bare emails),
2//! a port of micromark-extension-gfm-autolink-literal's state machine:
3//! previous-character gates, the underscore rule for the domain's last two segments,
4//! balanced-paren paths, and trailing-punctuation trimming via lookahead.
5
6// Non-boundary breaks below use [`is_whitespace`]:
7// micromark checks `markdownLineEndingOrSpace(code) || unicodeWhitespace(code)`,
8// which is exactly JS `\s`, not Rust's `char::is_whitespace`.
9use crate::syntax::unicode::{is_punctuation, is_whitespace};
10
11/// Attempts a literal at `pos`, `prev` is the character before it.
12/// Returns the end position.
13pub fn scan(t: &str, pos: usize, prev: Option<char>) -> Option<usize> {
14    match t.as_bytes()[pos] {
15        b'h' | b'H' => email(t, pos, prev).or_else(|| http(t, pos, prev)),
16        b'w' | b'W' => email(t, pos, prev).or_else(|| www(t, pos, prev)),
17        _ => email(t, pos, prev),
18    }
19}
20
21/// The kind of an autolink literal.
22///
23/// Derived with the same disambiguation the parser used
24/// (an `@`-run that scans as a full email wins over `www.`);
25/// public so renderers/printers derive the href scheme identically.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum LiteralKind {
28    /// `http://` / `https://`: the text already carries its scheme.
29    Http,
30    /// `www.…`: prepend `http://`.
31    Www,
32    /// Bare email: prepend `mailto:`.
33    Email,
34}
35
36/// Classifies a matched autolink literal.
37pub fn literal_kind(text: &str) -> LiteralKind {
38    let b = text.as_bytes();
39    // Byte-wise compare: domains accept non-ASCII,
40    // so a `str` slice at 7/8 could split a character (`www.aa日x`) and panic.
41    if b.len() >= 7 && b[..7].eq_ignore_ascii_case(b"http://")
42        || b.len() >= 8 && b[..8].eq_ignore_ascii_case(b"https://")
43    {
44        return LiteralKind::Http;
45    }
46    if b.contains(&b'@') && email(text, 0, None) == Some(text.len()) {
47        return LiteralKind::Email;
48    }
49    LiteralKind::Www
50}
51
52fn is_atext(c: char) -> bool {
53    c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.' | '_')
54}
55
56fn char_at(t: &str, i: usize) -> Option<char> {
57    t.get(i..).and_then(|s| s.chars().next())
58}
59
60fn email(t: &str, pos: usize, prev: Option<char>) -> Option<usize> {
61    if prev.is_some_and(|c| c == '/' || is_atext(c)) {
62        return None;
63    }
64    let bytes = t.as_bytes();
65    let mut i = pos;
66    while bytes.get(i).copied().is_some_and(|b| is_atext(b as char) && b.is_ascii()) {
67        i += 1;
68    }
69    if i == pos || bytes.get(i) != Some(&b'@') {
70        return None;
71    }
72    i += 1;
73    let (mut data, mut dot) = (false, false);
74    loop {
75        match bytes.get(i) {
76            Some(b'.') => {
77                // The dot joins the domain only when followed by more of it
78                if !bytes.get(i + 1).copied().is_some_and(|b| b.is_ascii_alphanumeric()) {
79                    break;
80                }
81                dot = true;
82                i += 1;
83            }
84            Some(b'-' | b'_') => {
85                data = true;
86                i += 1;
87            }
88            Some(&b) if b.is_ascii_alphanumeric() => {
89                data = true;
90                i += 1;
91            }
92            _ => break,
93        }
94    }
95    // The domain must end with a letter
96    (data && dot && bytes.get(i - 1).copied().is_some_and(|b| b.is_ascii_alphabetic())).then_some(i)
97}
98
99fn www(t: &str, pos: usize, prev: Option<char>) -> Option<usize> {
100    // micromark's `previousWww`: a few ASCII punctuation marks
101    // or `markdownLineEndingOrSpace` (ASCII space/tab/line ending only, so NBSP blocks the link).
102    if !matches!(prev, None | Some('(' | '*' | '_' | '[' | ']' | '~' | ' ' | '\t' | '\n')) {
103        return None;
104    }
105    let bytes = t.as_bytes();
106    let prefix = bytes.get(pos..pos + 4)?;
107    if !prefix[..3].iter().all(|b| matches!(b, b'w' | b'W')) || prefix[3] != b'.' {
108        return None;
109    }
110    let mut i = pos + 4;
111    bytes.get(i)?;
112    domain(t, &mut i)?;
113    path(t, &mut i);
114    Some(i)
115}
116
117fn http(t: &str, pos: usize, prev: Option<char>) -> Option<usize> {
118    if prev.is_some_and(|c| c.is_ascii_alphabetic()) {
119        return None;
120    }
121    let bytes = t.as_bytes();
122    let mut i = pos;
123    while bytes.get(i).copied().is_some_and(|b| b.is_ascii_alphabetic()) && i - pos < 5 {
124        i += 1;
125    }
126    if !t[pos..i].eq_ignore_ascii_case("http") && !t[pos..i].eq_ignore_ascii_case("https") {
127        return None;
128    }
129    if t.get(i..i + 3) != Some("://") {
130        return None;
131    }
132    i += 3;
133    let next = char_at(t, i)?;
134    // micromark's `afterProtocol`: ASCII controls only (`asciiControl`)
135    if is_whitespace(next) || next.is_ascii_control() || is_punctuation(next) {
136        return None;
137    }
138    domain(t, &mut i)?;
139    path(t, &mut i);
140    Some(i)
141}
142
143/// Domain run;
144/// fails on an underscore in the last two dot-separated segments
145/// or when nothing substantial was consumed.
146fn domain(t: &str, i: &mut usize) -> Option<()> {
147    let (mut underscore_last, mut underscore_prev, mut seen) = (false, false, false);
148    let mut not_trail_until = 0;
149    while let Some(c) = char_at(t, *i) {
150        match c {
151            '.' | '_' => {
152                if *i >= not_trail_until {
153                    match trail_check(t, *i) {
154                        Ok(()) => break,
155                        Err(scanned_to) => not_trail_until = scanned_to,
156                    }
157                }
158                if c == '_' {
159                    underscore_last = true;
160                } else {
161                    underscore_prev = underscore_last;
162                    underscore_last = false;
163                }
164                *i += 1;
165            }
166            c if is_whitespace(c) => break,
167            c if c != '-' && is_punctuation(c) => break,
168            c => {
169                seen = true;
170                *i += c.len_utf8();
171            }
172        }
173    }
174    (!underscore_prev && !underscore_last && seen).then_some(())
175}
176
177/// Path run: parens balance;
178/// a trail-class character ends the path when everything after it (to whitespace/EOF/`<`) is trail.
179fn path(t: &str, i: &mut usize) {
180    let (mut open, mut close) = (0usize, 0usize);
181    let mut not_trail_until = 0;
182    while let Some(c) = char_at(t, *i) {
183        match c {
184            '(' => {
185                open += 1;
186                *i += 1;
187            }
188            ')' if close < open => {
189                close += 1;
190                *i += 1;
191            }
192            '!' | '"' | '&' | '\'' | ')' | '*' | ',' | '.' | ':' | ';' | '<' | '?' | ']' | '_'
193            | '~' => {
194                if *i >= not_trail_until {
195                    match trail_check(t, *i) {
196                        Ok(()) => break,
197                        Err(scanned_to) => not_trail_until = scanned_to,
198                    }
199                }
200                if c == ')' {
201                    close += 1;
202                }
203                *i += 1;
204            }
205            c if is_whitespace(c) => break,
206            c => *i += c.len_utf8(),
207        }
208    }
209}
210
211/// Whether everything from `i` to whitespace/EOF/`<` is trailing punctuation (and thus not part of the link).
212/// `Err` carries how far the scan reached.
213/// Failed check fails identically for every trail-class position before that point,
214/// so callers memoize it to stay linear on long punctuation runs.
215/// (The `&…;` shape trims regardless of whether it names a real entity — deliberately unlike `decode::entity`.)
216fn trail_check(t: &str, mut i: usize) -> Result<(), usize> {
217    let bytes = t.as_bytes();
218    loop {
219        let Some(c) = char_at(t, i) else { return Ok(()) };
220        match c {
221            '<' => return Ok(()),
222            c if is_whitespace(c) => return Ok(()),
223            '!' | '"' | '\'' | ')' | '*' | ',' | '.' | ':' | ';' | '?' | '_' | '~' => i += 1,
224            '&' => {
225                i += 1;
226                let run = bytes[i..].iter().take_while(|b| b.is_ascii_alphabetic()).count();
227                if run == 0 || bytes.get(i + run) != Some(&b';') {
228                    return Err(i + run);
229                }
230                i += run + 1;
231            }
232            ']' => {
233                i += 1;
234                match char_at(t, i) {
235                    None | Some('(' | '[') => return Ok(()),
236                    Some(c) if is_whitespace(c) => return Ok(()),
237                    _ => {}
238                }
239            }
240            _ => return Err(i),
241        }
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn literal_kinds() {
251        assert_eq!(literal_kind("http://x.y"), LiteralKind::Http);
252        assert_eq!(literal_kind("HTTPS://x.y"), LiteralKind::Http);
253        assert_eq!(literal_kind("www.x.y"), LiteralKind::Www);
254        assert_eq!(literal_kind("a@b.cd"), LiteralKind::Email);
255        assert_eq!(literal_kind("www.aa日x"), LiteralKind::Www, "non-ASCII must not panic");
256    }
257
258    #[test]
259    fn scans_with_trailing_punctuation_trimmed() {
260        assert_eq!(scan("www.x.y.", 0, None), Some(7));
261        assert_eq!(scan("http://x.y/p(q)", 0, None), Some(15));
262        assert_eq!(scan("http://x.y/p)", 0, None), Some(12), "unbalanced paren excluded");
263        assert_eq!(scan("a@b.cd,", 0, None), Some(6));
264    }
265
266    #[test]
267    fn previous_character_gates() {
268        assert_eq!(scan("www.x.y", 0, Some(' ')), Some(7));
269        assert_eq!(scan("www.x.y", 0, Some('_')), Some(7), "micromark's `previousWww` admits `_`");
270        assert_eq!(scan("www.x.y", 0, Some('a')), None);
271        assert_eq!(scan("www.x.y", 0, Some('\u{A0}')), None, "NBSP is not a markdown space");
272        assert_eq!(scan("http://x.y", 0, Some('a')), None);
273        assert_eq!(scan("http://x.y", 0, Some('1')), Some(10));
274        assert_eq!(scan("a@b.cd", 0, Some('/')), None);
275        assert_eq!(scan("a@b.cd", 0, Some('.')), None, "atext before an email is part of it");
276    }
277}