Skip to main content

rich_ext/
hyperlink.rs

1//! Semantic hyperlinks for URLs, paths, source locations and references.
2//!
3//! A [`Hyperlinker`] turns what it recognises in a [`Text`] into OSC 8 links:
4//! `http(s)://` URLs, file paths with an optional `:line[:column]`, and — when
5//! a repository is known — `#123` and `owner/repo#123` references. Links are
6//! plain style attributes, so a console that is not a terminal renders the same
7//! text with no escape codes: the fallback is the text itself.
8//!
9//! File links use `file://` URLs (`file:///abs/path#12`, the form core's
10//! `LogRender` uses) unless an editor template such as
11//! `vscode://file{path}:{line}:{column}` is set (`{path}` is absolute, so it
12//! already starts with `/`). Relative paths resolve
13//! against a base directory when one is given.
14//!
15//! It is a [`Highlighter`], so it plugs into anything that takes one, such as
16//! the log handler; diagnostics and stack traces link their locations through it.
17
18use std::path::{Path, PathBuf};
19use std::sync::OnceLock;
20
21use fancy_regex::Regex;
22use rich::{Highlighter, Style, Text};
23
24/// Recognises linkable spans in text and builds their URLs.
25#[derive(Clone, Debug)]
26pub struct Hyperlinker {
27    enabled: bool,
28    urls: bool,
29    paths: bool,
30    base_dir: Option<PathBuf>,
31    editor: Option<String>,
32    repository: Option<String>,
33}
34
35impl Default for Hyperlinker {
36    fn default() -> Self {
37        Hyperlinker {
38            enabled: true,
39            urls: true,
40            paths: true,
41            base_dir: None,
42            editor: None,
43            repository: None,
44        }
45    }
46}
47
48/// What a scan found, as byte offsets into the text.
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct Link {
51    /// Start byte of the linked span.
52    pub start: usize,
53    /// End byte of the linked span.
54    pub end: usize,
55    /// The link target.
56    pub url: String,
57}
58
59fn url_pattern() -> &'static Regex {
60    static PATTERN: OnceLock<Regex> = OnceLock::new();
61    PATTERN.get_or_init(|| {
62        Regex::new(r#"https?://[^\s<>"'`]+[^\s<>"'`.,;:!?)\]}]"#).expect("valid URL pattern")
63    })
64}
65
66fn path_pattern() -> &'static Regex {
67    static PATTERN: OnceLock<Regex> = OnceLock::new();
68    PATTERN.get_or_init(|| {
69        // An absolute, `./`, `../` or `~/` path; a relative path with a
70        // directory whose last part has an extension; or a bare file name
71        // followed by a line. Then an optional `:line[:column]`.
72        Regex::new(concat!(
73            r"(?<![\w/.:~-])(?P<path>",
74            r"(?:/|\./|\.\./|~/)[\w.\-/+@]+",
75            r"|[\w.\-+@]+(?:/[\w.\-+@]+)*/[\w\-+@]+\.[A-Za-z0-9]{1,8}",
76            r"|[\w\-+@]+\.[A-Za-z][A-Za-z0-9]{0,7}(?=:\d)",
77            r")(?::(?P<line>\d+)(?::(?P<column>\d+))?)?(?![\w/])",
78        ))
79        .expect("valid path pattern")
80    })
81}
82
83fn reference_pattern() -> &'static Regex {
84    static PATTERN: OnceLock<Regex> = OnceLock::new();
85    PATTERN.get_or_init(|| {
86        Regex::new(r"(?<![\w/#])(?P<repo>[\w.-]+/[\w.-]+)?#(?P<number>\d+)\b")
87            .expect("valid reference pattern")
88    })
89}
90
91impl Hyperlinker {
92    /// Link URLs and paths; references need [`repository`](Self::repository).
93    pub fn new() -> Self {
94        Hyperlinker::default()
95    }
96
97    /// A linker that links nothing (the plain fallback, chosen explicitly).
98    pub fn disabled() -> Self {
99        Hyperlinker {
100            enabled: false,
101            ..Hyperlinker::default()
102        }
103    }
104
105    /// Turn linking on or off.
106    #[must_use]
107    pub fn enabled(mut self, enabled: bool) -> Self {
108        self.enabled = enabled;
109        self
110    }
111
112    /// Whether this linker adds links at all.
113    pub fn is_enabled(&self) -> bool {
114        self.enabled
115    }
116
117    /// Link `http(s)://` URLs (default on).
118    #[must_use]
119    pub fn urls(mut self, urls: bool) -> Self {
120        self.urls = urls;
121        self
122    }
123
124    /// Link file paths and `path:line:column` locations (default on).
125    #[must_use]
126    pub fn paths(mut self, paths: bool) -> Self {
127        self.paths = paths;
128        self
129    }
130
131    /// Resolve relative paths against `dir`.
132    #[must_use]
133    pub fn base_dir(mut self, dir: impl Into<PathBuf>) -> Self {
134        self.base_dir = Some(dir.into());
135        self
136    }
137
138    /// Link files through an editor URL template instead of `file://`, for
139    /// example `vscode://file{path}:{line}:{column}`. `{path}` is absolute and
140    /// already starts with `/`, so the template has no slash of its own before
141    /// it. `{line}` and `{column}` default to 1 when a location has none.
142    #[must_use]
143    pub fn editor(mut self, template: impl Into<String>) -> Self {
144        self.editor = Some(template.into());
145        self
146    }
147
148    /// The repository web URL (`https://github.com/owner/repo`) that `#123`
149    /// references link into. `owner/repo#123` links into that repository on
150    /// the same host.
151    #[must_use]
152    pub fn repository(mut self, url: impl Into<String>) -> Self {
153        self.repository = Some(url.into().trim_end_matches('/').to_string());
154        self
155    }
156
157    /// The URL for a file location, or `None` when linking is off.
158    pub fn file_url(
159        &self,
160        path: &str,
161        line: Option<usize>,
162        column: Option<usize>,
163    ) -> Option<String> {
164        if !self.enabled {
165            return None;
166        }
167        let expanded = self.resolve(path);
168        let path = expanded.to_string_lossy();
169        Some(match &self.editor {
170            Some(template) => template
171                .replace("{path}", &encode_path(&path))
172                .replace("{line}", &line.unwrap_or(1).to_string())
173                .replace("{column}", &column.unwrap_or(1).to_string()),
174            None => {
175                let mut url = format!("file://{}", encode_path(&path));
176                if let Some(line) = line {
177                    url.push_str(&format!("#{line}"));
178                }
179                url
180            }
181        })
182    }
183
184    /// The URL for issue or pull request `number`, in `repo` (`owner/name`)
185    /// or the configured repository.
186    pub fn reference_url(&self, repo: Option<&str>, number: u64) -> Option<String> {
187        if !self.enabled {
188            return None;
189        }
190        let base = self.repository.as_deref()?;
191        let base = match repo {
192            Some(repo) => {
193                // Same host, other repository: keep the scheme and host.
194                let host_end = base
195                    .find("://")
196                    .and_then(|scheme| base[scheme + 3..].find('/').map(|slash| scheme + 3 + slash))
197                    .unwrap_or(base.len());
198                format!("{}/{repo}", &base[..host_end])
199            }
200            None => base.to_string(),
201        };
202        Some(format!("{base}/issues/{number}"))
203    }
204
205    fn resolve(&self, path: &str) -> PathBuf {
206        let path = path.strip_prefix("./").unwrap_or(path);
207        let path = match path.strip_prefix("~/") {
208            Some(rest) => match std::env::var_os("HOME") {
209                Some(home) => Path::new(&home).join(rest),
210                None => PathBuf::from(path),
211            },
212            None => PathBuf::from(path),
213        };
214        match (&self.base_dir, path.is_absolute()) {
215            (Some(base), false) => base.join(path),
216            _ => path,
217        }
218    }
219
220    /// Everything linkable in `text`, in order, without overlaps: URLs first,
221    /// then references, then paths outside those.
222    pub fn find(&self, text: &str) -> Vec<Link> {
223        if !self.enabled {
224            return Vec::new();
225        }
226        let mut links: Vec<Link> = Vec::new();
227        let taken = |links: &[Link], start: usize, end: usize| {
228            links
229                .iter()
230                .any(|link| start < link.end && link.start < end)
231        };
232        if self.urls {
233            for found in url_pattern().find_iter(text).flatten() {
234                links.push(Link {
235                    start: found.start(),
236                    end: found.end(),
237                    url: found.as_str().to_string(),
238                });
239            }
240        }
241        if self.repository.is_some() {
242            for captures in reference_pattern().captures_iter(text).flatten() {
243                let whole = captures.get(0).expect("whole match");
244                if taken(&links, whole.start(), whole.end()) {
245                    continue;
246                }
247                let repo = captures.name("repo").map(|repo| repo.as_str());
248                let Ok(number) = captures["number"].parse() else {
249                    continue;
250                };
251                if let Some(url) = self.reference_url(repo, number) {
252                    links.push(Link {
253                        start: whole.start(),
254                        end: whole.end(),
255                        url,
256                    });
257                }
258            }
259        }
260        if self.paths {
261            for captures in path_pattern().captures_iter(text).flatten() {
262                let whole = captures.get(0).expect("whole match");
263                if taken(&links, whole.start(), whole.end()) {
264                    continue;
265                }
266                let number = |name: &str| captures.name(name).and_then(|m| m.as_str().parse().ok());
267                if let Some(url) =
268                    self.file_url(&captures["path"], number("line"), number("column"))
269                {
270                    links.push(Link {
271                        start: whole.start(),
272                        end: whole.end(),
273                        url,
274                    });
275                }
276            }
277        }
278        links.sort_by_key(|link| link.start);
279        links
280    }
281
282    /// Add a link span for everything [`find`](Self::find) recognises.
283    pub fn link(&self, text: &mut Text) {
284        for link in self.find(text.plain()) {
285            text.stylize(Style::new().with_link(link.url), link.start, link.end);
286        }
287    }
288
289    /// `path:line:column` as text linked to the location, in `style`.
290    pub fn location(
291        &self,
292        path: &str,
293        line: Option<usize>,
294        column: Option<usize>,
295        style: impl Into<rich::StyleType>,
296    ) -> Text {
297        let mut label = path.to_string();
298        if let Some(line) = line {
299            label.push_str(&format!(":{line}"));
300            if let Some(column) = column {
301                label.push_str(&format!(":{column}"));
302            }
303        }
304        // The label is shown as is, so a control code in it would run.
305        let mut text = Text::styled(crate::sanitize_terminal_controls(&label), style);
306        if let Some(url) = self.file_url(path, line, column) {
307            let end = text.plain().len();
308            text.stylize(Style::new().with_link(url), 0, end);
309        }
310        text
311    }
312}
313
314impl Highlighter for Hyperlinker {
315    fn highlight(&self, text: &mut Text) {
316        self.link(text);
317    }
318}
319
320/// Percent-encode every byte outside RFC 3986's path characters (unreserved,
321/// sub-delims, `:`, `@` and `/`), non-ASCII as its UTF-8 bytes. Paths come
322/// from untrusted text such as stack traces, and the URL is written inside an
323/// OSC 8 sequence: a raw BEL or ESC would end it early and run what follows.
324fn encode_path(path: &str) -> String {
325    use std::fmt::Write;
326    let path = path.replace('\\', "/");
327    let mut out = String::with_capacity(path.len());
328    for byte in path.bytes() {
329        let keep = byte.is_ascii_alphanumeric()
330            || matches!(
331                byte,
332                b'-' | b'.'
333                    | b'_'
334                    | b'~'
335                    | b'!'
336                    | b'$'
337                    | b'&'
338                    | b'\''
339                    | b'('
340                    | b')'
341                    | b'*'
342                    | b'+'
343                    | b','
344                    | b';'
345                    | b'='
346                    | b':'
347                    | b'@'
348                    | b'/'
349            );
350        if keep {
351            out.push(byte as char);
352        } else {
353            let _ = write!(out, "%{byte:02X}");
354        }
355    }
356    // `C:/x` becomes `/C:/x`, so `file://` + path is a valid file URL.
357    if out.as_bytes().get(1) == Some(&b':') {
358        out.insert(0, '/');
359    }
360    out
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    fn found(linker: &Hyperlinker, text: &str) -> Vec<(String, String)> {
368        linker
369            .find(text)
370            .into_iter()
371            .map(|link| (text[link.start..link.end].to_string(), link.url))
372            .collect()
373    }
374
375    #[test]
376    fn urls_drop_trailing_punctuation() {
377        let links = found(
378            &Hyperlinker::new(),
379            "see https://example.com/a?b=1. Then (https://x.io)",
380        );
381        assert_eq!(links[0].0, "https://example.com/a?b=1");
382        assert_eq!(links[1].0, "https://x.io");
383    }
384
385    #[test]
386    fn locations_link_with_line_and_column() {
387        let linker = Hyperlinker::new().base_dir("/work");
388        let links = found(&linker, "error at src/main.rs:12:5 and /etc/hosts");
389        assert_eq!(
390            links[0],
391            (
392                "src/main.rs:12:5".into(),
393                "file:///work/src/main.rs#12".into()
394            )
395        );
396        assert_eq!(links[1], ("/etc/hosts".into(), "file:///etc/hosts".into()));
397    }
398
399    #[test]
400    fn editor_templates_fill_line_and_column() {
401        let linker = Hyperlinker::new().editor("vscode://file{path}:{line}:{column}");
402        assert_eq!(
403            linker.file_url("/a b/c.rs", Some(3), None).unwrap(),
404            "vscode://file/a%20b/c.rs:3:1"
405        );
406    }
407
408    #[test]
409    fn paths_encode_everything_outside_the_uri_path_set() {
410        let linker = Hyperlinker::new();
411        assert_eq!(
412            linker
413                .file_url("/tmp/a\x07\x1b]0;P\u{9b}\x7f\"<>^`{|}[é].py", Some(2), None)
414                .unwrap(),
415            "file:///tmp/a%07%1B%5D0;P%C2%9B%7F%22%3C%3E%5E%60%7B%7C%7D%5B%C3%A9%5D.py#2"
416        );
417        // Ordinary paths, and the RFC 3986 path characters, stay as they were.
418        assert_eq!(
419            linker
420                .file_url("/a b/c-d_e.f~g/h!$&'()*+,;=:@%#?.rs", None, None)
421                .unwrap(),
422            "file:///a%20b/c-d_e.f~g/h!$&'()*+,;=:@%25%23%3F.rs"
423        );
424        assert_eq!(
425            linker.file_url("C:\\x\\y.rs", None, None).unwrap(),
426            "file:///C:/x/y.rs"
427        );
428        let editor = Hyperlinker::new().editor("vscode://file{path}:{line}:{column}");
429        assert_eq!(
430            editor.file_url("/a\x1b\\b", Some(1), Some(2)).unwrap(),
431            "vscode://file/a%1B/b:1:2"
432        );
433    }
434
435    #[test]
436    fn references_need_a_repository() {
437        assert!(found(&Hyperlinker::new(), "fixes #12").is_empty());
438        let linker = Hyperlinker::new().repository("https://github.com/o/r/");
439        assert_eq!(
440            found(&linker, "fixes #12 and other/repo#3"),
441            vec![
442                ("#12".into(), "https://github.com/o/r/issues/12".into()),
443                (
444                    "other/repo#3".into(),
445                    "https://github.com/other/repo/issues/3".into()
446                ),
447            ]
448        );
449    }
450
451    #[test]
452    fn plain_words_and_versions_are_not_paths() {
453        let links = found(&Hyperlinker::new(), "version 1.2.3 of rich, e.g. done.");
454        assert!(links.is_empty(), "{links:?}");
455    }
456
457    #[test]
458    fn disabled_links_nothing() {
459        let mut text = Text::new("https://example.com src/lib.rs:1");
460        Hyperlinker::disabled().link(&mut text);
461        assert!(text.spans().is_empty());
462    }
463}