oxc_markdown_parser/inline/
gfm_autolink.rs1use crate::syntax::unicode::{is_punctuation, is_whitespace};
10
11pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum LiteralKind {
28 Http,
30 Www,
32 Email,
34}
35
36pub fn literal_kind(text: &str) -> LiteralKind {
38 let b = text.as_bytes();
39 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 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 (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 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 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
143fn 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
177fn 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
211fn 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}