Skip to main content

rotulus_layout/
linkify.rs

1//! Finding links in plain text.
2//!
3//! One detector for everything that asks "is this a link": autolinking a
4//! message body, the markdown `[label](url)` allowlist, and whether a
5//! clicked word is a URL. Keeping them on one scheme list is what stops
6//! them disagreeing — a URL that autolinks but may not be written as a
7//! markdown link, or the reverse, reads as a bug either way.
8//!
9//! The scheme list belongs to the application. A Hotline client wants
10//! `hotline://`, an IRC client `ircs://`; [`DEFAULT_SCHEMES`] is the
11//! set every chat client can agree on, and [`Linkifier::new`] takes a
12//! different one.
13//!
14//! Detection is deliberately lenient about what surrounds a URL and
15//! strict about what ends one: a link may abut an opening quote or
16//! bracket, and sentence punctuation after it is not part of it.
17
18use std::ops::Range;
19
20/// The schemes a view links when the application names none.
21///
22/// Each entry is the full prefix, so the two shapes of URL scheme are
23/// both expressible: `https://` and `mailto:`.
24pub const DEFAULT_SCHEMES: &[&str] = &[
25    "http://", "https://", "ftp://", "ftps://", "irc://", "ircs://", "mailto:", "magnet:",
26    "git://", "ssh://", "sftp://",
27];
28
29/// Scheme-less prefixes people type, and the scheme each one opens with.
30///
31/// `www.example.com` is how a lot of people still write a web address;
32/// without a scheme a launcher has nothing to dispatch on.
33const BARE_PREFIXES: &[(&str, &str)] = &[
34    ("www.", "https://"),
35    ("ftp.", "ftp://"),
36    ("irc.", "https://"),
37];
38
39/// A link detector over one scheme list.
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct Linkifier {
42    /// Lowercased prefixes, each ending in `:` or `://`.
43    schemes: Vec<String>,
44}
45
46impl Default for Linkifier {
47    fn default() -> Self {
48        Linkifier::new(DEFAULT_SCHEMES)
49    }
50}
51
52impl Linkifier {
53    /// A detector for exactly these scheme prefixes (`"https://"`,
54    /// `"mailto:"`). Matching is case-insensitive; empty entries are
55    /// ignored, and so is an entry without a `:`, which could never be
56    /// a scheme.
57    pub fn new<S: AsRef<str>>(schemes: impl IntoIterator<Item = S>) -> Linkifier {
58        let mut out: Vec<String> = Vec::new();
59        for s in schemes {
60            let s = s.as_ref().trim().to_ascii_lowercase();
61            if s.contains(':') && !out.contains(&s) {
62                out.push(s);
63            }
64        }
65        Linkifier { schemes: out }
66    }
67
68    /// The scheme prefixes this detector links.
69    pub fn schemes(&self) -> &[String] {
70        &self.schemes
71    }
72
73    fn scheme_at(&self, bytes: &[u8]) -> Option<usize> {
74        self.schemes
75            .iter()
76            .find(|s| starts_with_ignore_case(bytes, s.as_bytes()))
77            .map(|s| s.len())
78    }
79
80    fn bare_prefix_at(bytes: &[u8]) -> Option<(usize, &'static str)> {
81        BARE_PREFIXES
82            .iter()
83            .find(|(p, _)| starts_with_ignore_case(bytes, p.as_bytes()))
84            .map(|(p, scheme)| (p.len(), *scheme))
85    }
86
87    /// Whether `href` starts with one of the schemes. What a markdown
88    /// `[label](url)` is checked against: anything else — `javascript:`,
89    /// `data:`, `file:` — renders as the literal text that was typed.
90    pub fn allows(&self, href: &str) -> bool {
91        self.scheme_at(href.trim().as_bytes()).is_some()
92    }
93
94    /// Whether a word starts with a scheme or a bare `www.`-style prefix.
95    pub fn has_scheme(&self, word: &str) -> bool {
96        let b = word.as_bytes();
97        self.scheme_at(b).is_some() || Self::bare_prefix_at(b).is_some()
98    }
99
100    /// Whether a whitespace-delimited word is a link: a URL, or a bare
101    /// email address.
102    pub fn is_url(&self, word: &str) -> bool {
103        self.has_scheme(word) || is_email(word)
104    }
105
106    /// The form of `word` a launcher can open: a scheme is prepended to
107    /// bare `www.` hosts and email addresses, and anything already
108    /// carrying one comes back unchanged.
109    pub fn normalize(&self, word: &str) -> String {
110        let b = word.as_bytes();
111        if self.scheme_at(b).is_some() {
112            return word.to_string();
113        }
114        if let Some((_, scheme)) = Self::bare_prefix_at(b) {
115            return format!("{scheme}{word}");
116        }
117        if let (Some(at), Some(dot)) = (word.find('@'), word.rfind('.')) {
118            if at < dot {
119                return format!("mailto:{word}");
120            }
121        }
122        word.to_string()
123    }
124
125    /// Every link in `text`, as byte ranges on character boundaries.
126    ///
127    /// A link starts at the beginning of the text or after whitespace or
128    /// an opening delimiter, and runs to whitespace or a closing one.
129    /// Trailing sentence punctuation is dropped, and so is a closing
130    /// paren or bracket — unless the URL opened one itself, which is the
131    /// shape of a Wikipedia link. Bare email addresses are links too,
132    /// which [`Linkifier::normalize`] turns into `mailto:`.
133    pub fn scan(&self, text: &str) -> Vec<Range<usize>> {
134        let mut out = self.scan_urls(text);
135        let emails = scan_emails(text, &out);
136        if !emails.is_empty() {
137            out.extend(emails);
138            out.sort_by_key(|r| r.start);
139        }
140        out
141    }
142
143    fn scan_urls(&self, text: &str) -> Vec<Range<usize>> {
144        let bytes = text.as_bytes();
145        let mut out = Vec::new();
146        let mut i = 0usize;
147        while i < bytes.len() {
148            let at_boundary = i == 0
149                || matches!(bytes[i - 1], b'<' | b'(' | b'[' | b'"' | b'\'')
150                || bytes[i - 1].is_ascii_whitespace();
151            let prefix = if at_boundary {
152                self.scheme_at(&bytes[i..])
153                    .or_else(|| Self::bare_prefix_at(&bytes[i..]).map(|(n, _)| n))
154            } else {
155                None
156            };
157            let Some(prefix) = prefix else {
158                i += 1;
159                continue;
160            };
161            let mut end = i + prefix;
162            while end < bytes.len()
163                && !matches!(
164                    bytes[end],
165                    b' ' | b'\t' | b'\n' | b'\r' | b'<' | b'>' | b'"' | b'\''
166                )
167            {
168                end += 1;
169            }
170            let len = trim_trailing_punct(&bytes[i..end]);
171            if len > prefix {
172                out.push(i..i + len);
173            }
174            i = end;
175        }
176        out
177    }
178}
179
180fn starts_with_ignore_case(hay: &[u8], prefix: &[u8]) -> bool {
181    hay.len() >= prefix.len() && hay[..prefix.len()].eq_ignore_ascii_case(prefix)
182}
183
184/// Bare email addresses in `text` that aren't inside one of `taken`.
185fn scan_emails(text: &str, taken: &[Range<usize>]) -> Vec<Range<usize>> {
186    let bytes = text.as_bytes();
187    let opens = |c: u8| c.is_ascii_whitespace() || matches!(c, b'<' | b'(' | b'[' | b'"' | b'\'');
188    let closes = |c: u8| c.is_ascii_whitespace() || matches!(c, b'<' | b'>' | b'"' | b'\'');
189    let mut out: Vec<Range<usize>> = Vec::new();
190    for (at, _) in text.match_indices('@') {
191        if taken.iter().chain(&out).any(|r| r.contains(&at)) {
192            continue;
193        }
194        let start = bytes[..at]
195            .iter()
196            .rposition(|&c| opens(c))
197            .map_or(0, |i| i + 1);
198        let end = bytes[at..]
199            .iter()
200            .position(|&c| closes(c))
201            .map_or(bytes.len(), |i| at + i);
202        let len = trim_trailing_punct(&bytes[start..end]);
203        let word = &text[start..start + len];
204        // A local part and a domain with a dot in it, and nothing that
205        // makes it a URL the scheme pass declined.
206        if start < at && is_email(word) && !word.contains('/') && word.matches('@').count() == 1 {
207            out.push(start..start + len);
208        }
209    }
210    out
211}
212
213fn is_email(word: &str) -> bool {
214    match (word.find('@'), word.rfind('.')) {
215        (Some(at), Some(dot)) => at < dot && dot < word.len() - 1,
216        _ => false,
217    }
218}
219
220/// Length of `s` with trailing sentence punctuation removed.
221fn trim_trailing_punct(s: &[u8]) -> usize {
222    let mut len = s.len();
223    while len > 0 {
224        let c = s[len - 1];
225        let strip = matches!(
226            c,
227            b'.' | b',' | b';' | b':' | b'!' | b'?' | b')' | b']' | b'\'' | b'"'
228        );
229        if !strip {
230            break;
231        }
232        if c == b')' && s[..len].contains(&b'(') {
233            break;
234        }
235        if c == b']' && s[..len].contains(&b'[') {
236            break;
237        }
238        len -= 1;
239    }
240    len
241}