use carta_ast::{Attr, Inline, Target};
use super::scan::matches_at;
#[derive(Clone, Copy)]
enum Kind {
Uri,
Email,
}
impl Kind {
fn class(self) -> &'static str {
match self {
Self::Uri => "uri",
Self::Email => "email",
}
}
}
pub(crate) fn autolink_inlines(inlines: &mut Vec<Inline>, markdown: bool) {
let taken = std::mem::take(inlines);
let mut out = Vec::with_capacity(taken.len());
for inline in taken {
match inline {
Inline::Str(s) => split_text(&s, markdown, &mut out),
Inline::Emph(mut v) => out.push(Inline::Emph(recurse(&mut v, markdown))),
Inline::Underline(mut v) => out.push(Inline::Underline(recurse(&mut v, markdown))),
Inline::Strong(mut v) => out.push(Inline::Strong(recurse(&mut v, markdown))),
Inline::Strikeout(mut v) => out.push(Inline::Strikeout(recurse(&mut v, markdown))),
Inline::Superscript(mut v) => out.push(Inline::Superscript(recurse(&mut v, markdown))),
Inline::Subscript(mut v) => out.push(Inline::Subscript(recurse(&mut v, markdown))),
Inline::SmallCaps(mut v) => out.push(Inline::SmallCaps(recurse(&mut v, markdown))),
Inline::Quoted(q, mut v) => out.push(Inline::Quoted(q, recurse(&mut v, markdown))),
Inline::Span(a, mut v) => out.push(Inline::Span(a, recurse(&mut v, markdown))),
Inline::Image(a, mut v, t) => out.push(Inline::Image(a, recurse(&mut v, markdown), t)),
other => out.push(other),
}
}
*inlines = out;
}
fn recurse(inlines: &mut Vec<Inline>, markdown: bool) -> Vec<Inline> {
autolink_inlines(inlines, markdown);
std::mem::take(inlines)
}
struct Match {
start: usize,
end: usize,
href: String,
kind: Kind,
}
fn split_text(text: &str, markdown: bool, out: &mut Vec<Inline>) {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
let mut i = 0;
let mut emit_from = 0;
while i < len {
let found = match_url(&chars, i)
.or_else(|| (!markdown).then(|| match_www(&chars, i)).flatten())
.or_else(|| match_email(&chars, i, emit_from, markdown));
if let Some(m) = found {
push_text(&chars, emit_from, m.start, out);
if let Some(span) = chars.get(m.start..m.end) {
let label: String = span.iter().collect();
let attr = if markdown {
Attr {
id: String::new(),
classes: vec![m.kind.class().to_owned()],
attributes: Vec::new(),
}
} else {
Attr::default()
};
let url = if markdown {
super::scan::escape_uri(&m.href)
} else {
m.href
};
out.push(Inline::Link(
attr,
vec![Inline::Str(label)],
Target {
url,
title: String::new(),
},
));
}
emit_from = m.end;
i = m.end;
} else {
i += 1;
}
}
push_text(&chars, emit_from, len, out);
}
fn push_text(chars: &[char], a: usize, b: usize, out: &mut Vec<Inline>) {
if let Some(slice) = chars.get(a..b)
&& !slice.is_empty()
{
out.push(Inline::Str(slice.iter().collect()));
}
}
fn match_url(chars: &[char], i: usize) -> Option<Match> {
if alnum_before(chars, i) {
return None;
}
let scheme_len = url_scheme_len(chars, i)?;
let content_start = i + scheme_len;
let scan_end = forward_scan(chars, i);
if !valid_host(chars.get(content_start..scan_end)?) {
return None;
}
let end = trim_trailing(chars, content_start, scan_end);
if end <= content_start {
return None;
}
let href: String = chars.get(i..end)?.iter().collect();
Some(Match {
start: i,
end,
href,
kind: Kind::Uri,
})
}
fn valid_host(rest: &[char]) -> bool {
let mut labels = 0;
let mut i = 0;
loop {
let start = i;
while rest.get(i).is_some_and(|&c| is_label_char(c)) {
i += 1;
}
if i == start || matches!(rest.get(i - 1), Some('-' | '_')) {
break;
}
labels += 1;
if rest.get(i) != Some(&'.') {
break;
}
i += 1;
}
labels >= 2
}
fn is_label_char(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '-' | '_')
}
fn match_www(chars: &[char], i: usize) -> Option<Match> {
if alnum_before(chars, i) || !matches_at(chars, i, "www.") {
return None;
}
let content_start = i + 4;
let scan_end = forward_scan(chars, i);
if !valid_host(chars.get(i..scan_end)?) {
return None;
}
let end = trim_trailing(chars, content_start, scan_end);
if end <= content_start {
return None;
}
let label: String = chars.get(i..end)?.iter().collect();
let href = format!("http://{label}");
Some(Match {
start: i,
end,
href,
kind: Kind::Uri,
})
}
fn match_email(chars: &[char], at: usize, lower: usize, markdown: bool) -> Option<Match> {
if chars.get(at) != Some(&'@') {
return None;
}
let mut start = at;
while start > lower {
match chars.get(start - 1) {
Some(&c) if is_local_char(c) => start -= 1,
_ => break,
}
}
if start == at {
return None;
}
let mut end = at + 1;
while chars.get(end).is_some_and(|&c| is_domain_char(c)) {
end += 1;
}
while end > at + 1 && chars.get(end - 1) == Some(&'.') {
end -= 1;
}
let domain = chars.get(at + 1..end)?;
let ends_alnum = domain.last().is_some_and(char::is_ascii_alphanumeric);
let dotted_ok = markdown || domain.contains(&'.');
if !dotted_ok || !ends_alnum || domain.windows(2).any(|w| matches!(w, ['.', '.'])) {
return None;
}
let label: String = chars.get(start..end)?.iter().collect();
let href = format!("mailto:{label}");
Some(Match {
start,
end,
href,
kind: Kind::Email,
})
}
fn forward_scan(chars: &[char], from: usize) -> usize {
let mut depth: i32 = 0;
let mut j = from;
while let Some(&c) = chars.get(j) {
if c.is_whitespace() || c == '<' {
break;
}
match c {
'(' => depth += 1,
')' | ']' if depth == 0 => break,
')' => depth -= 1,
_ => {}
}
j += 1;
}
j
}
fn trim_trailing(chars: &[char], min: usize, mut end: usize) -> usize {
while end > min {
match chars.get(end - 1) {
Some('!' | '"' | '\'' | '*' | ',' | '.' | ':' | '?' | '_' | '~') => end -= 1,
Some(';') => {
let mut j = end - 1;
while j > min && chars.get(j - 1).is_some_and(|&c| is_entity_char(c)) {
j -= 1;
}
end = if j > min && chars.get(j - 1) == Some(&'&') {
j - 1
} else {
end - 1
};
}
_ => break,
}
}
end
}
fn url_scheme_len(chars: &[char], i: usize) -> Option<usize> {
["https://", "http://", "ftp://"]
.into_iter()
.find(|scheme| matches_at(chars, i, scheme))
.map(str::len)
}
fn alnum_before(chars: &[char], i: usize) -> bool {
i.checked_sub(1)
.and_then(|p| chars.get(p))
.is_some_and(|c| c.is_alphanumeric())
}
fn is_local_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-')
}
fn is_domain_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')
}
fn is_entity_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '#'
}
#[cfg(test)]
mod tests {
use super::*;
fn links(text: &str) -> Vec<(String, String)> {
classed_links(text, false)
.into_iter()
.map(|(label, href, _)| (label, href))
.collect()
}
fn classed_links(text: &str, markdown: bool) -> Vec<(String, String, Vec<String>)> {
let mut inlines = vec![Inline::Str(text.to_owned())];
autolink_inlines(&mut inlines, markdown);
inlines
.iter()
.filter_map(|inline| match inline {
Inline::Link(attr, label, target) => {
let text: String = label
.iter()
.map(|i| match i {
Inline::Str(s) => s.as_str(),
_ => "",
})
.collect();
Some((text, target.url.clone(), attr.classes.clone()))
}
_ => None,
})
.collect()
}
fn host(s: &str) -> bool {
valid_host(&s.chars().collect::<Vec<_>>())
}
fn scan(s: &str) -> usize {
forward_scan(&s.chars().collect::<Vec<_>>(), 0)
}
#[test]
fn valid_host_needs_two_well_formed_labels() {
assert!(host("example.com"));
assert!(host("a.b.c.com"));
assert!(host("-a.com")); assert!(host("ex_ample.com")); assert!(!host("localhost")); assert!(!host("a-.com")); assert!(!host("example.com_")); assert!(!host(".com")); assert!(!host("a..com")); }
#[test]
fn valid_host_reads_only_the_leading_domain() {
assert!(host("a.com..post"));
assert!(host("example.com:8080/path"));
assert!(host("example.com./x")); }
#[test]
fn forward_scan_balances_parens_and_stops_at_boundaries() {
assert_eq!(scan("http://e.com/a b"), 14); assert_eq!(scan("http://e.com/a(b)c)"), 18); assert_eq!(scan("http://e.com]x"), 12); assert_eq!(scan("http://e.com<x"), 12); }
#[test]
fn trim_trailing_drops_punctuation_and_entities() {
let chars: Vec<char> = "http://e.com/p.,".chars().collect();
assert_eq!(trim_trailing(&chars, 7, chars.len()), 14); let ent: Vec<char> = "http://e.com/p&".chars().collect();
assert_eq!(trim_trailing(&ent, 7, ent.len()), 14); }
#[test]
fn bare_url_www_and_email_become_links() {
assert_eq!(
links("see http://example.com/p?q=1 now"),
vec![(
"http://example.com/p?q=1".to_owned(),
"http://example.com/p?q=1".to_owned()
)]
);
assert_eq!(
links("at www.example.com today"),
vec![(
"www.example.com".to_owned(),
"http://www.example.com".to_owned()
)]
);
assert_eq!(
links("mail me@example.com please"),
vec![(
"me@example.com".to_owned(),
"mailto:me@example.com".to_owned()
)]
);
}
#[test]
fn trailing_sentence_punctuation_is_excluded() {
assert_eq!(
links("read http://example.com.")
.first()
.map(|l| l.1.clone()),
Some("http://example.com".to_owned())
);
}
#[test]
fn invalid_domains_do_not_link() {
assert!(links("ping http://localhost/here").is_empty());
assert!(links("ping http://a..b.com here").is_empty());
}
#[test]
fn existing_links_are_not_rescanned() {
let inner = Inline::Link(
Attr::default(),
vec![Inline::Str("http://example.com".to_owned())],
Target {
url: "http://example.com".to_owned(),
title: String::new(),
},
);
let mut inlines = vec![inner.clone()];
autolink_inlines(&mut inlines, false);
assert_eq!(inlines, vec![inner]);
}
#[test]
fn code_is_left_untouched() {
let code = Inline::Code(Attr::default(), "http://example.com".to_owned());
let mut inlines = vec![code.clone()];
autolink_inlines(&mut inlines, false);
assert_eq!(inlines, vec![code]);
}
#[test]
fn markdown_dialect_classes_links_and_accepts_single_label_email() {
assert_eq!(
classed_links("see http://example.com and 5@home now", true),
vec![
(
"http://example.com".to_owned(),
"http://example.com".to_owned(),
vec!["uri".to_owned()]
),
(
"5@home".to_owned(),
"mailto:5@home".to_owned(),
vec!["email".to_owned()]
),
]
);
assert!(classed_links("ping 5@home now", false).is_empty());
assert_eq!(
classed_links("at www.example.com today", false),
vec![(
"www.example.com".to_owned(),
"http://www.example.com".to_owned(),
Vec::new()
)]
);
}
}