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    pub fn enabled(mut self, enabled: bool) -> Self {
107        self.enabled = enabled;
108        self
109    }
110
111    /// Whether this linker adds links at all.
112    pub fn is_enabled(&self) -> bool {
113        self.enabled
114    }
115
116    /// Link `http(s)://` URLs (default on).
117    pub fn urls(mut self, urls: bool) -> Self {
118        self.urls = urls;
119        self
120    }
121
122    /// Link file paths and `path:line:column` locations (default on).
123    pub fn paths(mut self, paths: bool) -> Self {
124        self.paths = paths;
125        self
126    }
127
128    /// Resolve relative paths against `dir`.
129    pub fn base_dir(mut self, dir: impl Into<PathBuf>) -> Self {
130        self.base_dir = Some(dir.into());
131        self
132    }
133
134    /// Link files through an editor URL template instead of `file://`, for
135    /// example `vscode://file{path}:{line}:{column}`. `{path}` is absolute and
136    /// already starts with `/`, so the template has no slash of its own before
137    /// it. `{line}` and `{column}` default to 1 when a location has none.
138    pub fn editor(mut self, template: impl Into<String>) -> Self {
139        self.editor = Some(template.into());
140        self
141    }
142
143    /// The repository web URL (`https://github.com/owner/repo`) that `#123`
144    /// references link into. `owner/repo#123` links into that repository on
145    /// the same host.
146    pub fn repository(mut self, url: impl Into<String>) -> Self {
147        self.repository = Some(url.into().trim_end_matches('/').to_string());
148        self
149    }
150
151    /// The URL for a file location, or `None` when linking is off.
152    pub fn file_url(
153        &self,
154        path: &str,
155        line: Option<usize>,
156        column: Option<usize>,
157    ) -> Option<String> {
158        if !self.enabled {
159            return None;
160        }
161        let expanded = self.resolve(path);
162        let path = expanded.to_string_lossy();
163        Some(match &self.editor {
164            Some(template) => template
165                .replace("{path}", &encode_path(&path))
166                .replace("{line}", &line.unwrap_or(1).to_string())
167                .replace("{column}", &column.unwrap_or(1).to_string()),
168            None => {
169                let mut url = format!("file://{}", encode_path(&path));
170                if let Some(line) = line {
171                    url.push_str(&format!("#{line}"));
172                }
173                url
174            }
175        })
176    }
177
178    /// The URL for issue or pull request `number`, in `repo` (`owner/name`)
179    /// or the configured repository.
180    pub fn reference_url(&self, repo: Option<&str>, number: u64) -> Option<String> {
181        if !self.enabled {
182            return None;
183        }
184        let base = self.repository.as_deref()?;
185        let base = match repo {
186            Some(repo) => {
187                // Same host, other repository: keep the scheme and host.
188                let host_end = base
189                    .find("://")
190                    .and_then(|scheme| base[scheme + 3..].find('/').map(|slash| scheme + 3 + slash))
191                    .unwrap_or(base.len());
192                format!("{}/{repo}", &base[..host_end])
193            }
194            None => base.to_string(),
195        };
196        Some(format!("{base}/issues/{number}"))
197    }
198
199    fn resolve(&self, path: &str) -> PathBuf {
200        let path = path.strip_prefix("./").unwrap_or(path);
201        let path = match path.strip_prefix("~/") {
202            Some(rest) => match std::env::var_os("HOME") {
203                Some(home) => Path::new(&home).join(rest),
204                None => PathBuf::from(path),
205            },
206            None => PathBuf::from(path),
207        };
208        match (&self.base_dir, path.is_absolute()) {
209            (Some(base), false) => base.join(path),
210            _ => path,
211        }
212    }
213
214    /// Everything linkable in `text`, in order, without overlaps: URLs first,
215    /// then references, then paths outside those.
216    pub fn find(&self, text: &str) -> Vec<Link> {
217        if !self.enabled {
218            return Vec::new();
219        }
220        let mut links: Vec<Link> = Vec::new();
221        let taken = |links: &[Link], start: usize, end: usize| {
222            links
223                .iter()
224                .any(|link| start < link.end && link.start < end)
225        };
226        if self.urls {
227            for found in url_pattern().find_iter(text).flatten() {
228                links.push(Link {
229                    start: found.start(),
230                    end: found.end(),
231                    url: found.as_str().to_string(),
232                });
233            }
234        }
235        if self.repository.is_some() {
236            for captures in reference_pattern().captures_iter(text).flatten() {
237                let whole = captures.get(0).expect("whole match");
238                if taken(&links, whole.start(), whole.end()) {
239                    continue;
240                }
241                let repo = captures.name("repo").map(|repo| repo.as_str());
242                let Ok(number) = captures["number"].parse() else {
243                    continue;
244                };
245                if let Some(url) = self.reference_url(repo, number) {
246                    links.push(Link {
247                        start: whole.start(),
248                        end: whole.end(),
249                        url,
250                    });
251                }
252            }
253        }
254        if self.paths {
255            for captures in path_pattern().captures_iter(text).flatten() {
256                let whole = captures.get(0).expect("whole match");
257                if taken(&links, whole.start(), whole.end()) {
258                    continue;
259                }
260                let number = |name: &str| captures.name(name).and_then(|m| m.as_str().parse().ok());
261                if let Some(url) =
262                    self.file_url(&captures["path"], number("line"), number("column"))
263                {
264                    links.push(Link {
265                        start: whole.start(),
266                        end: whole.end(),
267                        url,
268                    });
269                }
270            }
271        }
272        links.sort_by_key(|link| link.start);
273        links
274    }
275
276    /// Add a link span for everything [`find`](Self::find) recognises.
277    pub fn link(&self, text: &mut Text) {
278        for link in self.find(text.plain()) {
279            text.stylize(Style::new().with_link(link.url), link.start, link.end);
280        }
281    }
282
283    /// `path:line:column` as text linked to the location, in `style`.
284    pub fn location(
285        &self,
286        path: &str,
287        line: Option<usize>,
288        column: Option<usize>,
289        style: impl Into<rich::StyleType>,
290    ) -> Text {
291        let mut label = path.to_string();
292        if let Some(line) = line {
293            label.push_str(&format!(":{line}"));
294            if let Some(column) = column {
295                label.push_str(&format!(":{column}"));
296            }
297        }
298        // The label is shown as is, so a control code in it would run.
299        let mut text = Text::styled(crate::sanitize_terminal_controls(&label), style);
300        if let Some(url) = self.file_url(path, line, column) {
301            let end = text.plain().len();
302            text.stylize(Style::new().with_link(url), 0, end);
303        }
304        text
305    }
306}
307
308impl Highlighter for Hyperlinker {
309    fn highlight(&self, text: &mut Text) {
310        self.link(text);
311    }
312}
313
314/// Percent-encode every byte outside RFC 3986's path characters (unreserved,
315/// sub-delims, `:`, `@` and `/`), non-ASCII as its UTF-8 bytes. Paths come
316/// from untrusted text such as stack traces, and the URL is written inside an
317/// OSC 8 sequence: a raw BEL or ESC would end it early and run what follows.
318fn encode_path(path: &str) -> String {
319    use std::fmt::Write;
320    let path = path.replace('\\', "/");
321    let mut out = String::with_capacity(path.len());
322    for byte in path.bytes() {
323        let keep = byte.is_ascii_alphanumeric()
324            || matches!(
325                byte,
326                b'-' | b'.'
327                    | b'_'
328                    | b'~'
329                    | b'!'
330                    | b'$'
331                    | b'&'
332                    | b'\''
333                    | b'('
334                    | b')'
335                    | b'*'
336                    | b'+'
337                    | b','
338                    | b';'
339                    | b'='
340                    | b':'
341                    | b'@'
342                    | b'/'
343            );
344        if keep {
345            out.push(byte as char);
346        } else {
347            let _ = write!(out, "%{byte:02X}");
348        }
349    }
350    // `C:/x` becomes `/C:/x`, so `file://` + path is a valid file URL.
351    if out.as_bytes().get(1) == Some(&b':') {
352        out.insert(0, '/');
353    }
354    out
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    fn found(linker: &Hyperlinker, text: &str) -> Vec<(String, String)> {
362        linker
363            .find(text)
364            .into_iter()
365            .map(|link| (text[link.start..link.end].to_string(), link.url))
366            .collect()
367    }
368
369    #[test]
370    fn urls_drop_trailing_punctuation() {
371        let links = found(
372            &Hyperlinker::new(),
373            "see https://example.com/a?b=1. Then (https://x.io)",
374        );
375        assert_eq!(links[0].0, "https://example.com/a?b=1");
376        assert_eq!(links[1].0, "https://x.io");
377    }
378
379    #[test]
380    fn locations_link_with_line_and_column() {
381        let linker = Hyperlinker::new().base_dir("/work");
382        let links = found(&linker, "error at src/main.rs:12:5 and /etc/hosts");
383        assert_eq!(
384            links[0],
385            (
386                "src/main.rs:12:5".into(),
387                "file:///work/src/main.rs#12".into()
388            )
389        );
390        assert_eq!(links[1], ("/etc/hosts".into(), "file:///etc/hosts".into()));
391    }
392
393    #[test]
394    fn editor_templates_fill_line_and_column() {
395        let linker = Hyperlinker::new().editor("vscode://file{path}:{line}:{column}");
396        assert_eq!(
397            linker.file_url("/a b/c.rs", Some(3), None).unwrap(),
398            "vscode://file/a%20b/c.rs:3:1"
399        );
400    }
401
402    #[test]
403    fn paths_encode_everything_outside_the_uri_path_set() {
404        let linker = Hyperlinker::new();
405        assert_eq!(
406            linker
407                .file_url("/tmp/a\x07\x1b]0;P\u{9b}\x7f\"<>^`{|}[é].py", Some(2), None)
408                .unwrap(),
409            "file:///tmp/a%07%1B%5D0;P%C2%9B%7F%22%3C%3E%5E%60%7B%7C%7D%5B%C3%A9%5D.py#2"
410        );
411        // Ordinary paths, and the RFC 3986 path characters, stay as they were.
412        assert_eq!(
413            linker
414                .file_url("/a b/c-d_e.f~g/h!$&'()*+,;=:@%#?.rs", None, None)
415                .unwrap(),
416            "file:///a%20b/c-d_e.f~g/h!$&'()*+,;=:@%25%23%3F.rs"
417        );
418        assert_eq!(
419            linker.file_url("C:\\x\\y.rs", None, None).unwrap(),
420            "file:///C:/x/y.rs"
421        );
422        let editor = Hyperlinker::new().editor("vscode://file{path}:{line}:{column}");
423        assert_eq!(
424            editor.file_url("/a\x1b\\b", Some(1), Some(2)).unwrap(),
425            "vscode://file/a%1B/b:1:2"
426        );
427    }
428
429    #[test]
430    fn references_need_a_repository() {
431        assert!(found(&Hyperlinker::new(), "fixes #12").is_empty());
432        let linker = Hyperlinker::new().repository("https://github.com/o/r/");
433        assert_eq!(
434            found(&linker, "fixes #12 and other/repo#3"),
435            vec![
436                ("#12".into(), "https://github.com/o/r/issues/12".into()),
437                (
438                    "other/repo#3".into(),
439                    "https://github.com/other/repo/issues/3".into()
440                ),
441            ]
442        );
443    }
444
445    #[test]
446    fn plain_words_and_versions_are_not_paths() {
447        let links = found(&Hyperlinker::new(), "version 1.2.3 of rich, e.g. done.");
448        assert!(links.is_empty(), "{links:?}");
449    }
450
451    #[test]
452    fn disabled_links_nothing() {
453        let mut text = Text::new("https://example.com src/lib.rs:1");
454        Hyperlinker::disabled().link(&mut text);
455        assert!(text.spans().is_empty());
456    }
457}