use std::ops::Range;
pub const DEFAULT_SCHEMES: &[&str] = &[
"http://", "https://", "ftp://", "ftps://", "irc://", "ircs://", "mailto:", "magnet:",
"git://", "ssh://", "sftp://",
];
const BARE_PREFIXES: &[(&str, &str)] = &[
("www.", "https://"),
("ftp.", "ftp://"),
("irc.", "https://"),
];
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Linkifier {
schemes: Vec<String>,
}
impl Default for Linkifier {
fn default() -> Self {
Linkifier::new(DEFAULT_SCHEMES)
}
}
impl Linkifier {
pub fn new<S: AsRef<str>>(schemes: impl IntoIterator<Item = S>) -> Linkifier {
let mut out: Vec<String> = Vec::new();
for s in schemes {
let s = s.as_ref().trim().to_ascii_lowercase();
if s.contains(':') && !out.contains(&s) {
out.push(s);
}
}
Linkifier { schemes: out }
}
pub fn schemes(&self) -> &[String] {
&self.schemes
}
fn scheme_at(&self, bytes: &[u8]) -> Option<usize> {
self.schemes
.iter()
.find(|s| starts_with_ignore_case(bytes, s.as_bytes()))
.map(|s| s.len())
}
fn bare_prefix_at(bytes: &[u8]) -> Option<(usize, &'static str)> {
BARE_PREFIXES
.iter()
.find(|(p, _)| starts_with_ignore_case(bytes, p.as_bytes()))
.map(|(p, scheme)| (p.len(), *scheme))
}
pub fn allows(&self, href: &str) -> bool {
self.scheme_at(href.trim().as_bytes()).is_some()
}
pub fn has_scheme(&self, word: &str) -> bool {
let b = word.as_bytes();
self.scheme_at(b).is_some() || Self::bare_prefix_at(b).is_some()
}
pub fn is_url(&self, word: &str) -> bool {
self.has_scheme(word) || is_email(word)
}
pub fn normalize(&self, word: &str) -> String {
let b = word.as_bytes();
if self.scheme_at(b).is_some() {
return word.to_string();
}
if let Some((_, scheme)) = Self::bare_prefix_at(b) {
return format!("{scheme}{word}");
}
if let (Some(at), Some(dot)) = (word.find('@'), word.rfind('.')) {
if at < dot {
return format!("mailto:{word}");
}
}
word.to_string()
}
pub fn scan(&self, text: &str) -> Vec<Range<usize>> {
let mut out = self.scan_urls(text);
let emails = scan_emails(text, &out);
if !emails.is_empty() {
out.extend(emails);
out.sort_by_key(|r| r.start);
}
out
}
fn scan_urls(&self, text: &str) -> Vec<Range<usize>> {
let bytes = text.as_bytes();
let mut out = Vec::new();
let mut i = 0usize;
while i < bytes.len() {
let at_boundary = i == 0
|| matches!(bytes[i - 1], b'<' | b'(' | b'[' | b'"' | b'\'')
|| bytes[i - 1].is_ascii_whitespace();
let prefix = if at_boundary {
self.scheme_at(&bytes[i..])
.or_else(|| Self::bare_prefix_at(&bytes[i..]).map(|(n, _)| n))
} else {
None
};
let Some(prefix) = prefix else {
i += 1;
continue;
};
let mut end = i + prefix;
while end < bytes.len()
&& !matches!(
bytes[end],
b' ' | b'\t' | b'\n' | b'\r' | b'<' | b'>' | b'"' | b'\''
)
{
end += 1;
}
let len = trim_trailing_punct(&bytes[i..end]);
if len > prefix {
out.push(i..i + len);
}
i = end;
}
out
}
}
fn starts_with_ignore_case(hay: &[u8], prefix: &[u8]) -> bool {
hay.len() >= prefix.len() && hay[..prefix.len()].eq_ignore_ascii_case(prefix)
}
fn scan_emails(text: &str, taken: &[Range<usize>]) -> Vec<Range<usize>> {
let bytes = text.as_bytes();
let opens = |c: u8| c.is_ascii_whitespace() || matches!(c, b'<' | b'(' | b'[' | b'"' | b'\'');
let closes = |c: u8| c.is_ascii_whitespace() || matches!(c, b'<' | b'>' | b'"' | b'\'');
let mut out: Vec<Range<usize>> = Vec::new();
for (at, _) in text.match_indices('@') {
if taken.iter().chain(&out).any(|r| r.contains(&at)) {
continue;
}
let start = bytes[..at]
.iter()
.rposition(|&c| opens(c))
.map_or(0, |i| i + 1);
let end = bytes[at..]
.iter()
.position(|&c| closes(c))
.map_or(bytes.len(), |i| at + i);
let len = trim_trailing_punct(&bytes[start..end]);
let word = &text[start..start + len];
if start < at && is_email(word) && !word.contains('/') && word.matches('@').count() == 1 {
out.push(start..start + len);
}
}
out
}
fn is_email(word: &str) -> bool {
match (word.find('@'), word.rfind('.')) {
(Some(at), Some(dot)) => at < dot && dot < word.len() - 1,
_ => false,
}
}
fn trim_trailing_punct(s: &[u8]) -> usize {
let mut len = s.len();
while len > 0 {
let c = s[len - 1];
let strip = matches!(
c,
b'.' | b',' | b';' | b':' | b'!' | b'?' | b')' | b']' | b'\'' | b'"'
);
if !strip {
break;
}
if c == b')' && s[..len].contains(&b'(') {
break;
}
if c == b']' && s[..len].contains(&b'[') {
break;
}
len -= 1;
}
len
}