1use std::path::{Path, PathBuf};
19use std::sync::OnceLock;
20
21use fancy_regex::Regex;
22use rich::{Highlighter, Style, Text};
23
24#[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#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct Link {
51 pub start: usize,
53 pub end: usize,
55 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 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 pub fn new() -> Self {
94 Hyperlinker::default()
95 }
96
97 pub fn disabled() -> Self {
99 Hyperlinker {
100 enabled: false,
101 ..Hyperlinker::default()
102 }
103 }
104
105 #[must_use]
107 pub fn enabled(mut self, enabled: bool) -> Self {
108 self.enabled = enabled;
109 self
110 }
111
112 pub fn is_enabled(&self) -> bool {
114 self.enabled
115 }
116
117 #[must_use]
119 pub fn urls(mut self, urls: bool) -> Self {
120 self.urls = urls;
121 self
122 }
123
124 #[must_use]
126 pub fn paths(mut self, paths: bool) -> Self {
127 self.paths = paths;
128 self
129 }
130
131 #[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 #[must_use]
143 pub fn editor(mut self, template: impl Into<String>) -> Self {
144 self.editor = Some(template.into());
145 self
146 }
147
148 #[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 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 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 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 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 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 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 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
320fn 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 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 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}