use super::AutolinkPrefix;
use crate::util::char::{classify, Kind};
pub(super) struct Match {
pub start: usize,
pub end: usize,
pub prefix: AutolinkPrefix,
}
#[derive(Default)]
pub(super) struct Trail {
start: usize,
tail: usize,
boundary: usize,
limit: usize,
}
impl Trail {
fn suffix(&mut self, source: &str, start: usize, limit: usize) -> (usize, usize) {
if self.limit == limit && start >= self.start && start < self.boundary {
return (self.tail.max(start), self.boundary);
}
let (tail, boundary) = terminal_suffix(source, start, limit);
*self = Self {
start,
tail,
boundary,
limit,
};
(tail, boundary)
}
}
pub(super) fn web(
source: &str,
min: usize,
marker: usize,
limit: usize,
suffix_cache: &mut Trail,
) -> Option<Match> {
let bytes = source.as_bytes();
let (start, domain, prefix) = match bytes[marker] {
b':' => {
let start = if marker >= min + 5
&& bytes[marker - 5..marker].eq_ignore_ascii_case(b"https")
{
marker - 5
} else if marker >= min + 4 && bytes[marker - 4..marker].eq_ignore_ascii_case(b"http") {
marker - 4
} else {
return None;
};
if (start > min && bytes[start - 1].is_ascii_alphabetic())
|| marker + 3 > limit
|| &bytes[marker + 1..marker + 3] != b"//"
{
return None;
}
(start, marker + 3, AutolinkPrefix::None)
}
b'.' => {
if marker < min + 3
|| !bytes[marker - 3..marker].eq_ignore_ascii_case(b"www")
|| marker + 1 == limit
{
return None;
}
let start = marker - 3;
if start > min
&& !matches!(
bytes[start - 1],
b'\t' | b'\r' | b'\n' | b' ' | b'(' | b'*' | b'_' | b'[' | b']' | b'~'
)
{
return None;
}
(start, start, AutolinkPrefix::Http)
}
_ => return None,
};
let (tail, boundary) = suffix_cache.suffix(source, domain, limit);
let mut at = domain;
let mut seen = false;
let mut underscore = false;
let mut previous_underscore = false;
while at < boundary {
match bytes[at] {
b'.' | b'_' => {
if at >= tail {
break;
}
if bytes[at] == b'_' {
underscore = true;
} else {
previous_underscore = underscore;
underscore = false;
}
at += 1;
}
b'-' => at += 1,
byte if byte.is_ascii() => {
if ascii_whitespace(byte) || byte.is_ascii_punctuation() {
break;
}
seen = true;
at += 1;
}
_ => {
let character = source[at..limit].chars().next().unwrap();
if classify(character) != Kind::Other {
break;
}
seen = true;
at += character.len_utf8();
}
}
}
if !seen || underscore || previous_underscore {
return None;
}
let mut opening = 0;
let mut closing = 0;
while at < boundary {
let byte = bytes[at];
if at >= tail && is_trailing(byte) && (byte != b')' || closing >= opening) {
break;
}
if byte == b'(' {
opening += 1;
}
if byte == b')' {
closing += 1;
}
at += if byte.is_ascii() {
1
} else {
source[at..limit].chars().next().unwrap().len_utf8()
};
}
Some(Match {
start,
end: at,
prefix,
})
}
fn terminal_suffix(source: &str, start: usize, limit: usize) -> (usize, usize) {
let bytes = source.as_bytes();
let mut at = start;
let mut tail = start;
while at < limit {
match bytes[at] {
b'<' => break,
b']' if at + 1 == limit
|| matches!(bytes[at + 1], b'\t' | b'\r' | b'\n' | b' ' | b'(' | b'[') =>
{
break
}
b'&' => {
let mut end = at + 1;
while end < limit && bytes[end].is_ascii_alphabetic() {
end += 1;
}
if end > at + 1 && bytes.get(end) == Some(&b';') && end < limit {
at = end + 1;
} else {
at += 1;
tail = at;
}
}
byte if is_trailing(byte) => at += 1,
byte if byte.is_ascii() => {
if ascii_whitespace(byte) {
break;
}
at += 1;
tail = at;
}
_ => {
let character = source[at..limit].chars().next().unwrap();
if classify(character) == Kind::Whitespace {
break;
}
at += character.len_utf8();
tail = at;
}
}
}
(tail, at)
}
fn ascii_whitespace(byte: u8) -> bool {
matches!(byte, b'\t'..=b'\r' | b' ')
}
fn is_trailing(byte: u8) -> bool {
matches!(
byte,
b'!' | b'"'
| b'\''
| b')'
| b'*'
| b','
| b'.'
| b':'
| b';'
| b'?'
| b'_'
| b'~'
| b']'
| b'&'
)
}
pub(super) fn email(source: &str, min: usize, at: usize, limit: usize) -> Option<Match> {
let bytes = source.as_bytes();
let mut start = at;
while start > min
&& matches!(bytes[start - 1], b'+' | b'-' | b'.' | b'0'..=b'9' | b'A'..=b'Z' | b'_' | b'a'..=b'z')
{
start -= 1;
}
if start == at || (start > min && bytes[start - 1] == b'/') {
return None;
}
let mut prefix = AutolinkPrefix::Mailto;
let mut xmpp = false;
if start > min && bytes[start - 1] == b':' {
let mut protocol = start - 1;
while protocol > min && bytes[protocol - 1].is_ascii_alphanumeric() {
protocol -= 1;
}
if bytes[protocol..start - 1].eq_ignore_ascii_case(b"mailto") {
start = protocol;
prefix = AutolinkPrefix::None;
} else if bytes[protocol..start - 1].eq_ignore_ascii_case(b"xmpp") {
start = protocol;
prefix = AutolinkPrefix::None;
xmpp = true;
}
}
let mut end = at + 1;
let mut dot = false;
while end < limit {
match bytes[end] {
b'-' | b'0'..=b'9' | b'A'..=b'Z' | b'_' | b'a'..=b'z' => {}
b'/' if xmpp => {}
b'.' if end + 1 < limit && bytes[end + 1].is_ascii_alphanumeric() => dot = true,
_ => break,
}
end += 1;
}
if end == at + 1 || !dot || !matches!(bytes[end - 1], b'.' | b'A'..=b'Z' | b'a'..=b'z') {
return None;
}
Some(Match { start, end, prefix })
}
#[cfg(test)]
mod tests {
use super::{email, web, Trail};
use alloc::string::ToString;
#[test]
fn web_domains_paths_and_terminal_suffixes() {
for (source, expected, prefix) in [
("https://example.com", "https://example.com", ""),
(
"(HTTPS://example.com/a(b)))",
"HTTPS://example.com/a(b)",
"",
),
("www.example.com&xxx;. ", "www.example.com", "http://"),
("www.example.com&xxx;b ", "www.example.com&xxx;b", "http://"),
("www.example.com&. ", "www.example.com&", "http://"),
(
"www.example.com/?q=a(business)\".",
"www.example.com/?q=a(business)",
"http://",
),
(
"www.example.com/?q=(business))+ok",
"www.example.com/?q=(business))+ok",
"http://",
),
("www.example.com](target)", "www.example.com", "http://"),
("www.example.com]x", "www.example.com]x", "http://"),
("www.example.com<after", "www.example.com", "http://"),
("www.點看.com/界\u{2003}after", "www.點看.com/界", "http://"),
("www.$", "www.$", "http://"),
("www.-a..b.c", "www.-a..b.c", "http://"),
("www.aaa_bbb.ccc.ddd", "www.aaa_bbb.ccc.ddd", "http://"),
("www. ", "www", "http://"),
("http://x.org\0", "http://x.org\0", ""),
] {
let marker = source
.find(':')
.unwrap_or_else(|| source.find('.').unwrap());
let found = web(source, 0, marker, source.len(), &mut Trail::default())
.expect("valid web fixture has a literal autolink");
assert_eq!(&source[found.start..found.end], expected, "{source:?}");
assert_eq!(found.prefix.as_str(), prefix, "{source:?}");
}
}
#[test]
fn web_prefixes_and_domain_rejections() {
for source in [
"www.",
"awww.example.com",
":www.example.com",
"ahttp://example.com",
"http:/example.com",
"ftp://example.com",
"http://",
"http://---/x",
"www.aaa_bbb.ccc",
"www.aaa.bbb_ccc",
"http://a_b.c/d",
] {
let marker = source
.find(':')
.unwrap_or_else(|| source.find('.').unwrap());
assert!(
web(source, 0, marker, source.len(), &mut Trail::default()).is_none(),
"{:?}",
source
);
}
assert!(web("www.a", 1, 3, 5, &mut Trail::default()).is_none());
assert!(web("http://a", 1, 4, 8, &mut Trail::default()).is_none());
assert!(web("www.a", 0, 0, 5, &mut Trail::default()).is_none());
let source = "xwww.a";
assert_eq!(
web(source, 1, 4, source.len(), &mut Trail::default())
.unwrap()
.start,
1
);
}
#[test]
fn repeated_invalid_domains_share_terminal_lookahead() {
let repeated = "http://a_b.c/".repeat(4096);
let source = repeated.clone() + "http://good.org/a(b))).";
let mut trail = Trail::default();
for start in (0..repeated.len()).step_by("http://a_b.c/".len()) {
assert!(web(&source, 0, start + 4, source.len(), &mut trail).is_none());
assert_eq!(trail.start, 7);
assert_eq!(trail.boundary, source.len());
}
let found = web(&source, 0, repeated.len() + 4, source.len(), &mut trail).unwrap();
assert_eq!(&source[found.start..found.end], "http://good.org/a(b)");
assert_eq!(trail.start, 7);
}
#[test]
fn terminal_cache_respects_boundaries_limits_and_ascii_classes() {
let source = "http://a_b.c http://good.org).";
let mut trail = Trail::default();
assert!(web(source, 0, 4, source.len(), &mut trail).is_none());
let old_boundary = trail.boundary;
let marker = source.rfind(':').unwrap();
let found = web(source, 0, marker, source.len(), &mut trail).unwrap();
assert_eq!(trail.start, marker + 3);
assert!(trail.start > old_boundary);
assert_eq!(&source[found.start..found.end], "http://good.org");
assert_eq!(trail.suffix(source, 0, 4), (4, 4));
assert_eq!(trail.limit, 4);
let source = "abc.)";
assert_eq!(trail.suffix(source, 0, source.len()), (3, 5));
assert_eq!(trail.suffix(source, 4, source.len()), (4, 5));
assert_eq!(trail.suffix(source, 1, source.len()), (3, 5));
assert_eq!(trail.suffix(source, 0, source.len()), (3, 5));
for byte in 0..=0x7f {
let mut source = "http://x/".to_string();
source.push(char::from(byte));
source.push('x');
let found = web(&source, 0, 4, source.len(), &mut Trail::default()).unwrap();
assert_eq!(
found.end < source.len(),
char::from(byte).is_whitespace() || byte == b'<',
"{byte}"
);
}
}
#[test]
fn email_protocols_domain_rules_and_text_boundaries() {
for (source, expected, prefix) in [
("a.b-c_d@a.b.", "a.b-c_d@a.b", "mailto:"),
(",example@example.com", "example@example.com", "mailto:"),
("mailto:a@example.org/path", "mailto:a@example.org", ""),
("XMPP:a@example.org/path.", "XMPP:a@example.org/path", ""),
("mmmmailto:a@example.org", "a@example.org", "mailto:"),
("a@a_b.c", "a@a_b.c", "mailto:"),
("aaa@a.b_.c", "aaa@a.b_.c", "mailto:"),
] {
let at = source.find('@').unwrap();
let found = email(source, 0, at, source.len())
.expect("valid email fixture has a literal autolink");
assert_eq!(&source[found.start..found.end], expected, "{source:?}");
assert_eq!(found.prefix.as_str(), prefix, "{source:?}");
}
for source in [
"a@domain",
"a@domain.",
"a@a.b-",
"a@a.b_",
"a@a.b0",
"@a.b",
"/a@a.b",
"a@界.com",
] {
let at = source.find('@').unwrap();
assert!(email(source, 0, at, source.len()).is_none(), "{:?}", source);
}
assert!(email("aa@a.b", 2, 2, 6).is_none());
}
}