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 pub fn enabled(mut self, enabled: bool) -> Self {
107 self.enabled = enabled;
108 self
109 }
110
111 pub fn is_enabled(&self) -> bool {
113 self.enabled
114 }
115
116 pub fn urls(mut self, urls: bool) -> Self {
118 self.urls = urls;
119 self
120 }
121
122 pub fn paths(mut self, paths: bool) -> Self {
124 self.paths = paths;
125 self
126 }
127
128 pub fn base_dir(mut self, dir: impl Into<PathBuf>) -> Self {
130 self.base_dir = Some(dir.into());
131 self
132 }
133
134 pub fn editor(mut self, template: impl Into<String>) -> Self {
139 self.editor = Some(template.into());
140 self
141 }
142
143 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 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 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 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 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 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 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 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
314fn 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 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 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}