Skip to main content

comrak/parser/
autolink.rs

1use crate::character_set::character_set;
2use crate::ctype::{isalnum, isalpha, isspace};
3use crate::nodes::{AstNode, NodeLink, NodeValue};
4use crate::parser::inlines::make_inline;
5use std::str;
6use typed_arena::Arena;
7use unicode_categories::UnicodeCategories;
8
9// TODO: this can probably be cleaned up a lot. It used to handle all three of
10// {url,www,email}_match, but now just the last of those.
11pub(crate) fn process_autolinks<'a>(
12    arena: &'a Arena<AstNode<'a>>,
13    node: &'a AstNode<'a>,
14    contents_str: &mut String,
15    relaxed_autolinks: bool,
16) {
17    let contents = contents_str.as_bytes();
18    let len = contents.len();
19    let mut i = 0;
20
21    while i < len {
22        let mut post_org = None;
23        let mut bracket_opening = 0;
24
25        // cmark-gfm ignores links inside brackets, such as `[[http://example.com]`
26        while i < len {
27            if !relaxed_autolinks {
28                match contents[i] {
29                    b'[' => {
30                        bracket_opening += 1;
31                    }
32                    b']' => {
33                        bracket_opening -= 1;
34                    }
35                    _ => (),
36                }
37
38                if bracket_opening > 0 {
39                    i += 1;
40                    continue;
41                }
42            }
43
44            if contents[i] == b'@' {
45                post_org = email_match(arena, contents, i, relaxed_autolinks);
46                if post_org.is_some() {
47                    break;
48                }
49            }
50            i += 1;
51        }
52
53        if let Some((post, reverse, skip)) = post_org {
54            i -= reverse;
55            node.insert_after(post);
56            if i + skip < len {
57                let remain = str::from_utf8(&contents[i + skip..]).unwrap();
58                assert!(!remain.is_empty());
59                post.insert_after(make_inline(
60                    arena,
61                    NodeValue::Text(remain.to_string()),
62                    (0, 1, 0, 1).into(),
63                ));
64            }
65            contents_str.truncate(i);
66            return;
67        }
68    }
69}
70
71pub fn www_match<'a>(
72    arena: &'a Arena<AstNode<'a>>,
73    contents: &[u8],
74    i: usize,
75    relaxed_autolinks: bool,
76) -> Option<(&'a AstNode<'a>, usize, usize)> {
77    const WWW_DELIMS: [bool; 256] = character_set!(b"*_~([");
78
79    if i > 0 && !isspace(contents[i - 1]) && !WWW_DELIMS[contents[i - 1] as usize] {
80        return None;
81    }
82
83    if !contents[i..].starts_with(b"www.") {
84        return None;
85    }
86
87    let mut link_end = match check_domain(&contents[i..], false) {
88        None => return None,
89        Some(link_end) => link_end,
90    };
91
92    while i + link_end < contents.len() && !isspace(contents[i + link_end]) {
93        // basic test to detect whether we're in a normal markdown link - not exhaustive
94        if relaxed_autolinks && contents[i + link_end - 1] == b']' && contents[i + link_end] == b'('
95        {
96            return None;
97        }
98        link_end += 1;
99    }
100
101    link_end = autolink_delim(&contents[i..], link_end, relaxed_autolinks);
102
103    let mut url = "http://".to_string();
104    url.push_str(str::from_utf8(&contents[i..link_end + i]).unwrap());
105
106    let inl = make_inline(
107        arena,
108        NodeValue::Link(NodeLink {
109            url,
110            title: String::new(),
111        }),
112        (0, 1, 0, 1).into(),
113    );
114
115    inl.append(make_inline(
116        arena,
117        NodeValue::Text(
118            str::from_utf8(&contents[i..link_end + i])
119                .unwrap()
120                .to_string(),
121        ),
122        (0, 1, 0, 1).into(),
123    ));
124    Some((inl, 0, link_end))
125}
126
127fn check_domain(data: &[u8], allow_short: bool) -> Option<usize> {
128    let mut np = 0;
129    let mut uscore1 = 0;
130    let mut uscore2 = 0;
131
132    for (i, c) in unsafe { str::from_utf8_unchecked(data) }.char_indices() {
133        if c == '\\' && i < data.len() - 1 {
134            // Ignore escaped characters per https://github.com/github/cmark-gfm/pull/292.
135            // Not sure I love this, but it tracks upstream ..
136        } else if c == '_' {
137            uscore2 += 1;
138        } else if c == '.' {
139            uscore1 = uscore2;
140            uscore2 = 0;
141            np += 1;
142        } else if !is_valid_hostchar(c) && c != '-' {
143            if uscore1 == 0 && uscore2 == 0 && (allow_short || np > 0) {
144                return Some(i);
145            }
146            return None;
147        }
148    }
149
150    if (uscore1 > 0 || uscore2 > 0) && np <= 10 {
151        None
152    } else if allow_short || np > 0 {
153        Some(data.len())
154    } else {
155        None
156    }
157}
158
159fn is_valid_hostchar(ch: char) -> bool {
160    !(ch.is_whitespace() || ch.is_punctuation() || ch.is_symbol())
161}
162
163fn autolink_delim(data: &[u8], mut link_end: usize, relaxed_autolinks: bool) -> usize {
164    const LINK_END_ASSORTMENT: [bool; 256] = character_set!(b"?!.,:*_~'\"");
165
166    for (i, &b) in data.iter().enumerate().take(link_end) {
167        if b == b'<' {
168            link_end = i;
169            break;
170        }
171    }
172
173    while link_end > 0 {
174        let cclose = data[link_end - 1];
175
176        // Allow any number of matching parentheses (as recognised in copen/cclose)
177        // at the end of the URL.  If there is a greater number of closing
178        // parentheses than opening ones, we remove one character from the end of
179        // the link.
180        let mut copen = if cclose == b')' { Some(b'(') } else { None };
181
182        if relaxed_autolinks && copen.is_none() {
183            // allow balancing of `[]` and `{}` just like `()`
184            copen = if cclose == b']' {
185                Some(b'[')
186            } else if cclose == b'}' {
187                Some(b'{')
188            } else {
189                None
190            };
191        }
192
193        if LINK_END_ASSORTMENT[cclose as usize] {
194            link_end -= 1;
195        } else if cclose == b';' {
196            let mut new_end = link_end - 2;
197
198            while new_end > 0 && isalpha(data[new_end]) {
199                new_end -= 1;
200            }
201
202            if new_end < link_end - 2 && data[new_end] == b'&' {
203                link_end = new_end;
204            } else {
205                link_end -= 1;
206            }
207        } else if let Some(copen) = copen {
208            let mut opening = 0;
209            let mut closing = 0;
210            for &b in data.iter().take(link_end) {
211                if b == copen {
212                    opening += 1;
213                } else if b == cclose {
214                    closing += 1;
215                }
216            }
217
218            if closing <= opening {
219                break;
220            }
221
222            link_end -= 1;
223        } else {
224            break;
225        }
226    }
227
228    link_end
229}
230
231pub fn url_match<'a>(
232    arena: &'a Arena<AstNode<'a>>,
233    contents: &[u8],
234    i: usize,
235    relaxed_autolinks: bool,
236) -> Option<(&'a AstNode<'a>, usize, usize)> {
237    const SCHEMES: [&[u8]; 3] = [b"http", b"https", b"ftp"];
238
239    let size = contents.len();
240
241    if size - i < 4 || contents[i + 1] != b'/' || contents[i + 2] != b'/' {
242        return None;
243    }
244
245    let mut rewind = 0;
246    while rewind < i && isalpha(contents[i - rewind - 1]) {
247        rewind += 1;
248    }
249
250    if !relaxed_autolinks {
251        let cond = |s: &&[u8]| size - i + rewind >= s.len() && &&contents[i - rewind..i] == s;
252        if !SCHEMES.iter().any(cond) {
253            return None;
254        }
255    }
256
257    let mut link_end = match check_domain(&contents[i + 3..], true) {
258        None => return None,
259        Some(link_end) => link_end,
260    };
261
262    while link_end < size - i && !isspace(contents[i + link_end]) {
263        // basic test to detect whether we're in a normal markdown link - not exhaustive
264        if relaxed_autolinks
265            && link_end > 0
266            && contents[i + link_end - 1] == b']'
267            && contents[i + link_end] == b'('
268        {
269            return None;
270        }
271        link_end += 1;
272    }
273
274    link_end = autolink_delim(&contents[i..], link_end, relaxed_autolinks);
275
276    let url = str::from_utf8(&contents[i - rewind..i + link_end])
277        .unwrap()
278        .to_string();
279    let inl = make_inline(
280        arena,
281        NodeValue::Link(NodeLink {
282            url: url.clone(),
283            title: String::new(),
284        }),
285        (0, 1, 0, 1).into(),
286    );
287
288    inl.append(make_inline(
289        arena,
290        NodeValue::Text(url),
291        (0, 1, 0, 1).into(),
292    ));
293    Some((inl, rewind, rewind + link_end))
294}
295
296fn email_match<'a>(
297    arena: &'a Arena<AstNode<'a>>,
298    contents: &[u8],
299    i: usize,
300    relaxed_autolinks: bool,
301) -> Option<(&'a AstNode<'a>, usize, usize)> {
302    const EMAIL_OK_SET: [bool; 256] = character_set!(b".+-_");
303
304    let size = contents.len();
305
306    let mut auto_mailto = true;
307    let mut is_xmpp = false;
308    let mut rewind = 0;
309
310    while rewind < i {
311        let c = contents[i - rewind - 1];
312
313        if isalnum(c) || EMAIL_OK_SET[c as usize] {
314            rewind += 1;
315            continue;
316        }
317
318        if c == b':' {
319            if validate_protocol("mailto", contents, i - rewind - 1) {
320                auto_mailto = false;
321                rewind += 1;
322                continue;
323            }
324
325            if validate_protocol("xmpp", contents, i - rewind - 1) {
326                is_xmpp = true;
327                auto_mailto = false;
328                rewind += 1;
329                continue;
330            }
331        }
332
333        break;
334    }
335
336    if rewind == 0 {
337        return None;
338    }
339
340    let mut link_end = 1;
341    let mut np = 0;
342
343    while link_end < size - i {
344        let c = contents[i + link_end];
345
346        if isalnum(c) {
347            // empty
348        } else if c == b'@' {
349            return None;
350        } else if c == b'.' && link_end < size - i - 1 && isalnum(contents[i + link_end + 1]) {
351            np += 1;
352        } else if c == b'/' && is_xmpp {
353            // xmpp allows a `/` in the url
354        } else if c != b'-' && c != b'_' {
355            break;
356        }
357
358        link_end += 1;
359    }
360
361    if link_end < 2
362        || np == 0
363        || (!isalpha(contents[i + link_end - 1]) && contents[i + link_end - 1] != b'.')
364    {
365        return None;
366    }
367
368    link_end = autolink_delim(&contents[i..], link_end, relaxed_autolinks);
369    if link_end == 0 {
370        return None;
371    }
372
373    let mut url = if auto_mailto {
374        "mailto:".to_string()
375    } else {
376        "".to_string()
377    };
378    let text = str::from_utf8(&contents[i - rewind..link_end + i]).unwrap();
379    url.push_str(text);
380
381    let inl = make_inline(
382        arena,
383        NodeValue::Link(NodeLink {
384            url,
385            title: String::new(),
386        }),
387        (0, 1, 0, 1).into(),
388    );
389
390    inl.append(make_inline(
391        arena,
392        NodeValue::Text(text.to_string()),
393        (0, 1, 0, 1).into(),
394    ));
395    Some((inl, rewind, rewind + link_end))
396}
397
398fn validate_protocol(protocol: &str, contents: &[u8], cursor: usize) -> bool {
399    let size = contents.len();
400    let mut rewind = 0;
401
402    while rewind < cursor && isalpha(contents[cursor - rewind - 1]) {
403        rewind += 1;
404    }
405
406    size - cursor + rewind >= protocol.len()
407        && &contents[cursor - rewind..cursor] == protocol.as_bytes()
408}