1use alacritty_terminal::{
2 Term,
3 event::EventListener,
4 grid::Dimensions,
5 index::{Boundary, Column, Direction as AlacDirection, Point as AlacPoint},
6 term::{
7 cell::Flags,
8 search::{Match, RegexIter, RegexSearch},
9 },
10};
11use fancy_regex::Regex;
12use log::{info, warn};
13use std::{
14 ops::{Index, Range},
15 time::{Duration, Instant},
16};
17
18const URL_REGEX: &str = r#"(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file://|git://|ssh:|ftp://)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>"\s{-}\^โจโฉ`']+"#;
19const WIDE_CHAR_SPACERS: Flags =
20 Flags::from_bits(Flags::LEADING_WIDE_CHAR_SPACER.bits() | Flags::WIDE_CHAR_SPACER.bits())
21 .unwrap();
22
23pub(super) struct RegexSearches {
24 url_regex: RegexSearch,
25 path_hyperlink_regexes: Vec<Regex>,
26 path_hyperlink_timeout: Duration,
27}
28
29impl Default for RegexSearches {
30 fn default() -> Self {
31 Self {
32 url_regex: RegexSearch::new(URL_REGEX).unwrap(),
33 path_hyperlink_regexes: Vec::default(),
34 path_hyperlink_timeout: Duration::default(),
35 }
36 }
37}
38impl RegexSearches {
39 pub(super) fn new(
40 path_hyperlink_regexes: impl IntoIterator<Item: AsRef<str>>,
41 path_hyperlink_timeout_ms: u64,
42 ) -> Self {
43 Self {
44 url_regex: RegexSearch::new(URL_REGEX).unwrap(),
45 path_hyperlink_regexes: path_hyperlink_regexes
46 .into_iter()
47 .filter_map(|regex| {
48 Regex::new(regex.as_ref())
49 .inspect_err(|error| {
50 warn!(
51 concat!(
52 "Ignoring path hyperlink regex specified in ",
53 "`terminal.path_hyperlink_regexes`:\n\n\t{}\n\nError: {}",
54 ),
55 regex.as_ref(),
56 error
57 );
58 })
59 .ok()
60 })
61 .collect(),
62 path_hyperlink_timeout: Duration::from_millis(path_hyperlink_timeout_ms),
63 }
64 }
65}
66
67pub(super) fn find_from_grid_point<T: EventListener>(
68 term: &Term<T>,
69 point: AlacPoint,
70 regex_searches: &mut RegexSearches,
71) -> Option<(String, bool, Match)> {
72 let grid = term.grid();
73 let link = grid.index(point).hyperlink();
74 let found_word = if let Some(ref url) = link {
75 let mut min_index = point;
76 loop {
77 let new_min_index = min_index.sub(term, Boundary::Cursor, 1);
78 if new_min_index == min_index || grid.index(new_min_index).hyperlink() != link {
79 break;
80 } else {
81 min_index = new_min_index
82 }
83 }
84
85 let mut max_index = point;
86 loop {
87 let new_max_index = max_index.add(term, Boundary::Cursor, 1);
88 if new_max_index == max_index || grid.index(new_max_index).hyperlink() != link {
89 break;
90 } else {
91 max_index = new_max_index
92 }
93 }
94
95 let url = url.uri().to_owned();
96 let url_match = min_index..=max_index;
97
98 Some((url, true, url_match))
99 } else {
100 let (line_start, line_end) = (term.line_search_left(point), term.line_search_right(point));
101 if let Some((url, url_match)) = RegexIter::new(
102 line_start,
103 line_end,
104 AlacDirection::Right,
105 term,
106 &mut regex_searches.url_regex,
107 )
108 .find(|rm| rm.contains(&point))
109 .map(|url_match| {
110 let url = term.bounds_to_string(*url_match.start(), *url_match.end());
111 sanitize_url_punctuation(url, url_match, term)
112 }) {
113 Some((url, true, url_match))
114 } else {
115 path_match(
116 &term,
117 line_start,
118 line_end,
119 point,
120 &mut regex_searches.path_hyperlink_regexes,
121 regex_searches.path_hyperlink_timeout,
122 )
123 .map(|(path, path_match)| (path, false, path_match))
124 }
125 };
126
127 found_word.map(|(maybe_url_or_path, is_url, word_match)| {
128 if is_url {
129 // Treat "file://" IRIs like file paths to ensure
130 // that line numbers at the end of the path are
131 // handled correctly
132 if let Some(path) = maybe_url_or_path.strip_prefix("file://") {
133 (path.to_string(), false, word_match)
134 } else {
135 (maybe_url_or_path, true, word_match)
136 }
137 } else {
138 (maybe_url_or_path, false, word_match)
139 }
140 })
141}
142
143fn sanitize_url_punctuation<T: EventListener>(
144 url: String,
145 url_match: Match,
146 term: &Term<T>,
147) -> (String, Match) {
148 let mut sanitized_url = url;
149 let mut chars_trimmed = 0;
150
151 // First, handle parentheses balancing using single traversal
152 let (open_parens, close_parens) =
153 sanitized_url
154 .chars()
155 .fold((0, 0), |(opens, closes), c| match c {
156 '(' => (opens + 1, closes),
157 ')' => (opens, closes + 1),
158 _ => (opens, closes),
159 });
160
161 // Trim unbalanced closing parentheses
162 if close_parens > open_parens {
163 let mut remaining_close = close_parens;
164 while sanitized_url.ends_with(')') && remaining_close > open_parens {
165 sanitized_url.pop();
166 chars_trimmed += 1;
167 remaining_close -= 1;
168 }
169 }
170
171 // Handle trailing periods
172 if sanitized_url.ends_with('.') {
173 let trailing_periods = sanitized_url
174 .chars()
175 .rev()
176 .take_while(|&c| c == '.')
177 .count();
178
179 if trailing_periods > 1 {
180 sanitized_url.truncate(sanitized_url.len() - trailing_periods);
181 chars_trimmed += trailing_periods;
182 } else if trailing_periods == 1
183 && let Some(second_last_char) = sanitized_url.chars().rev().nth(1)
184 && (second_last_char.is_alphanumeric() || second_last_char == '/')
185 {
186 sanitized_url.pop();
187 chars_trimmed += 1;
188 }
189 }
190
191 if chars_trimmed > 0 {
192 let new_end = url_match.end().sub(term, Boundary::Grid, chars_trimmed);
193 let sanitized_match = Match::new(*url_match.start(), new_end);
194 (sanitized_url, sanitized_match)
195 } else {
196 (sanitized_url, url_match)
197 }
198}
199
200fn path_match<T>(
201 term: &Term<T>,
202 line_start: AlacPoint,
203 line_end: AlacPoint,
204 hovered: AlacPoint,
205 path_hyperlink_regexes: &mut Vec<Regex>,
206 path_hyperlink_timeout: Duration,
207) -> Option<(String, Match)> {
208 if path_hyperlink_regexes.is_empty() || path_hyperlink_timeout.as_millis() == 0 {
209 return None;
210 }
211 debug_assert!(line_start <= hovered);
212 debug_assert!(line_end >= hovered);
213 let search_start_time = Instant::now();
214
215 let timed_out = || {
216 let elapsed_time = Instant::now().saturating_duration_since(search_start_time);
217 (elapsed_time > path_hyperlink_timeout)
218 .then_some((elapsed_time.as_millis(), path_hyperlink_timeout.as_millis()))
219 };
220
221 // This used to be: `let line = term.bounds_to_string(line_start, line_end)`, however, that
222 // api compresses tab characters into a single space, whereas we require a cell accurate
223 // string representation of the line. The below algorithm does this, but seems a bit odd.
224 // Maybe there is a clean api for doing this, but I couldn't find it.
225 let mut line = String::with_capacity(
226 (line_end.line.0 - line_start.line.0 + 1) as usize * term.grid().columns(),
227 );
228 let first_cell = &term.grid()[line_start];
229 line.push(first_cell.c);
230 let mut start_offset = 0;
231 let mut hovered_point_byte_offset = None;
232
233 if !first_cell.flags.intersects(WIDE_CHAR_SPACERS) {
234 start_offset += first_cell.c.len_utf8();
235 if line_start == hovered {
236 hovered_point_byte_offset = Some(0);
237 }
238 }
239
240 for cell in term.grid().iter_from(line_start) {
241 if cell.point > line_end {
242 break;
243 }
244 let is_spacer = cell.flags.intersects(WIDE_CHAR_SPACERS);
245 if cell.point == hovered {
246 debug_assert!(hovered_point_byte_offset.is_none());
247 if start_offset > 0 && cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
248 // If we hovered on a trailing spacer, back up to the end of the previous char's bytes.
249 start_offset -= 1;
250 }
251 hovered_point_byte_offset = Some(start_offset);
252 } else if cell.point < hovered && !is_spacer {
253 start_offset += cell.c.len_utf8();
254 }
255
256 if !is_spacer {
257 line.push(match cell.c {
258 '\t' => ' ',
259 c @ _ => c,
260 });
261 }
262 }
263 let line = line.trim_ascii_end();
264 let hovered_point_byte_offset = hovered_point_byte_offset?;
265 let found_from_range = |path_range: Range<usize>,
266 link_range: Range<usize>,
267 position: Option<(u32, Option<u32>)>| {
268 let advance_point_by_str = |mut point: AlacPoint, s: &str| {
269 for _ in s.chars() {
270 point = term
271 .expand_wide(point, AlacDirection::Right)
272 .add(term, Boundary::Grid, 1);
273 }
274
275 // There does not appear to be an alacritty api that is
276 // "move to start of current wide char", so we have to do it ourselves.
277 let flags = term.grid().index(point).flags;
278 if flags.contains(Flags::LEADING_WIDE_CHAR_SPACER) {
279 AlacPoint::new(point.line + 1, Column(0))
280 } else if flags.contains(Flags::WIDE_CHAR_SPACER) {
281 AlacPoint::new(point.line, point.column - 1)
282 } else {
283 point
284 }
285 };
286
287 let link_start = advance_point_by_str(line_start, &line[..link_range.start]);
288 let link_end = advance_point_by_str(link_start, &line[link_range]);
289 let link_match = link_start
290 ..=term
291 .expand_wide(link_end, AlacDirection::Left)
292 .sub(term, Boundary::Grid, 1);
293
294 (
295 {
296 let mut path = line[path_range].to_string();
297 position.inspect(|(line, column)| {
298 path += &format!(":{line}");
299 column.inspect(|column| path += &format!(":{column}"));
300 });
301 path
302 },
303 link_match,
304 )
305 };
306
307 for regex in path_hyperlink_regexes {
308 let mut path_found = false;
309
310 for captures in regex.captures_iter(&line) {
311 let captures = match captures {
312 Ok(captures) => captures,
313 Err(error) => {
314 warn!("Error '{error}' searching for path hyperlinks in line: {line}");
315 info!(
316 "Skipping match from path hyperlinks with regex: {}",
317 regex.as_str()
318 );
319 continue;
320 }
321 };
322 path_found = true;
323 let match_range = captures.get(0).unwrap().range();
324 let (path_range, line_column) = if let Some(path) = captures.name("path") {
325 let parse = |name: &str| {
326 captures
327 .name(name)
328 .and_then(|capture| capture.as_str().parse().ok())
329 };
330
331 (
332 path.range(),
333 parse("line").map(|line| (line, parse("column"))),
334 )
335 } else {
336 (match_range.clone(), None)
337 };
338 let link_range = captures
339 .name("link")
340 .map_or_else(|| match_range.clone(), |link| link.range());
341
342 if !link_range.contains(&hovered_point_byte_offset) {
343 // No match, just skip.
344 continue;
345 }
346 let found = found_from_range(path_range, link_range, line_column);
347
348 if found.1.contains(&hovered) {
349 return Some(found);
350 }
351 }
352
353 if path_found {
354 return None;
355 }
356
357 if let Some((timed_out_ms, timeout_ms)) = timed_out() {
358 warn!("Timed out processing path hyperlink regexes after {timed_out_ms}ms");
359 info!("{timeout_ms}ms time out specified in `terminal.path_hyperlink_timeout_ms`");
360 return None;
361 }
362 }
363
364 None
365}
366
367#[cfg(test)]
368mod tests {
369 use crate::terminal_settings::TerminalSettings;
370
371 use super::*;
372 use alacritty_terminal::{
373 event::VoidListener,
374 grid::Dimensions,
375 index::{Boundary, Column, Line, Point as AlacPoint},
376 term::{Config, cell::Flags, test::TermSize},
377 vte::ansi::Handler,
378 };
379 use fancy_regex::Regex;
380 use settings::{self, Settings, SettingsContent};
381 use std::{cell::RefCell, ops::RangeInclusive, path::PathBuf, rc::Rc};
382 use url::Url;
383 use util::paths::PathWithPosition;
384
385 fn re_test(re: &str, hay: &str, expected: Vec<&str>) {
386 let results: Vec<_> = Regex::new(re)
387 .unwrap()
388 .find_iter(hay)
389 .map(|m| m.unwrap().as_str())
390 .collect();
391 assert_eq!(results, expected);
392 }
393
394 #[test]
395 fn test_url_regex() {
396 re_test(
397 URL_REGEX,
398 "test http://example.com test 'https://website1.com' test mailto:bob@example.com train",
399 vec![
400 "http://example.com",
401 "https://website1.com",
402 "mailto:bob@example.com",
403 ],
404 );
405 }
406
407 #[test]
408 fn test_url_parentheses_sanitization() {
409 // Test our sanitize_url_parentheses function directly
410 let test_cases = vec![
411 // Cases that should be sanitized (unbalanced parentheses)
412 ("https://www.google.com/)", "https://www.google.com/"),
413 ("https://example.com/path)", "https://example.com/path"),
414 ("https://test.com/))", "https://test.com/"),
415 // Cases that should NOT be sanitized (balanced parentheses)
416 (
417 "https://en.wikipedia.org/wiki/Example_(disambiguation)",
418 "https://en.wikipedia.org/wiki/Example_(disambiguation)",
419 ),
420 ("https://test.com/(hello)", "https://test.com/(hello)"),
421 (
422 "https://example.com/path(1)(2)",
423 "https://example.com/path(1)(2)",
424 ),
425 // Edge cases
426 ("https://test.com/", "https://test.com/"),
427 ("https://example.com", "https://example.com"),
428 ];
429
430 for (input, expected) in test_cases {
431 // Create a minimal terminal for testing
432 let term = Term::new(Config::default(), &TermSize::new(80, 24), VoidListener);
433
434 // Create a dummy match that spans the entire input
435 let start_point = AlacPoint::new(Line(0), Column(0));
436 let end_point = AlacPoint::new(Line(0), Column(input.len()));
437 let dummy_match = Match::new(start_point, end_point);
438
439 let (result, _) = sanitize_url_punctuation(input.to_string(), dummy_match, &term);
440 assert_eq!(result, expected, "Failed for input: {}", input);
441 }
442 }
443
444 #[test]
445 fn test_url_periods_sanitization() {
446 // Test URLs with trailing periods (sentence punctuation)
447 let test_cases = vec![
448 // Cases that should be sanitized (trailing periods likely punctuation)
449 ("https://example.com.", "https://example.com"),
450 (
451 "https://github.com/zed-industries/zed.",
452 "https://github.com/zed-industries/zed",
453 ),
454 (
455 "https://example.com/path/file.html.",
456 "https://example.com/path/file.html",
457 ),
458 (
459 "https://example.com/file.pdf.",
460 "https://example.com/file.pdf",
461 ),
462 ("https://example.com:8080.", "https://example.com:8080"),
463 ("https://example.com..", "https://example.com"),
464 (
465 "https://en.wikipedia.org/wiki/C.E.O.",
466 "https://en.wikipedia.org/wiki/C.E.O",
467 ),
468 // Cases that should NOT be sanitized (periods are part of URL structure)
469 (
470 "https://example.com/v1.0/api",
471 "https://example.com/v1.0/api",
472 ),
473 ("https://192.168.1.1", "https://192.168.1.1"),
474 ("https://sub.domain.com", "https://sub.domain.com"),
475 ];
476
477 for (input, expected) in test_cases {
478 // Create a minimal terminal for testing
479 let term = Term::new(Config::default(), &TermSize::new(80, 24), VoidListener);
480
481 // Create a dummy match that spans the entire input
482 let start_point = AlacPoint::new(Line(0), Column(0));
483 let end_point = AlacPoint::new(Line(0), Column(input.len()));
484 let dummy_match = Match::new(start_point, end_point);
485
486 // This test should initially fail since we haven't implemented period sanitization yet
487 let (result, _) = sanitize_url_punctuation(input.to_string(), dummy_match, &term);
488 assert_eq!(result, expected, "Failed for input: {}", input);
489 }
490 }
491
492 macro_rules! test_hyperlink {
493 ($($lines:expr),+; $hyperlink_kind:ident) => { {
494 use crate::terminal_hyperlinks::tests::line_cells_count;
495 use std::cmp;
496
497 let test_lines = vec![$($lines),+];
498 let (total_cells, longest_line_cells) =
499 test_lines.iter().copied()
500 .map(line_cells_count)
501 .fold((0, 0), |state, cells| (state.0 + cells, cmp::max(state.1, cells)));
502 let contains_tab_char = test_lines.iter().copied()
503 .map(str::chars).flatten().find(|&c| c == '\t');
504 let columns = if contains_tab_char.is_some() {
505 // This avoids tabs at end of lines causing whitespace-eating line wraps...
506 vec![longest_line_cells + 1]
507 } else {
508 // Alacritty has issues with 2 columns, use 3 as the minimum for now.
509 vec![3, longest_line_cells / 2, longest_line_cells + 1]
510 };
511 test_hyperlink!(
512 columns;
513 total_cells;
514 test_lines.iter().copied();
515 $hyperlink_kind
516 )
517 } };
518
519 ($columns:expr; $total_cells:expr; $lines:expr; $hyperlink_kind:ident) => { {
520 use crate::terminal_hyperlinks::tests::{ test_hyperlink, HyperlinkKind };
521
522 let source_location = format!("{}:{}", std::file!(), std::line!());
523 for columns in $columns {
524 test_hyperlink(columns, $total_cells, $lines, HyperlinkKind::$hyperlink_kind,
525 &source_location);
526 }
527 } };
528 }
529
530 mod path {
531 /// ๐ := **hovered** on following char
532 ///
533 /// ๐ := **hovered** on wide char spacer of previous full width char
534 ///
535 /// **`โนโบ`** := expected **hyperlink** match
536 ///
537 /// **`ยซยป`** := expected **path**, **row**, and **column** capture groups
538 ///
539 /// [**`cโ, cโ, โฆ, cโ;`**]โโโ := use specified terminal widths of `cโ, cโ, โฆ, cโ` **columns**
540 /// (defaults to `3, longest_line_cells / 2, longest_line_cells + 1;`)
541 ///
542 macro_rules! test_path {
543 ($($lines:literal),+) => { test_hyperlink!($($lines),+; Path) };
544 }
545
546 #[test]
547 fn simple() {
548 // Rust paths
549 // Just the path
550 test_path!("โนยซ/๐test/cool.rsยปโบ");
551 test_path!("โนยซ/test/cool๐.rsยปโบ");
552
553 // path and line
554 test_path!("โนยซ/๐test/cool.rsยป:ยซ4ยปโบ");
555 test_path!("โนยซ/test/cool.rsยป๐:ยซ4ยปโบ");
556 test_path!("โนยซ/test/cool.rsยป:ยซ๐4ยปโบ");
557 test_path!("โนยซ/๐test/cool.rsยป(ยซ4ยป)โบ");
558 test_path!("โนยซ/test/cool.rsยป๐(ยซ4ยป)โบ");
559 test_path!("โนยซ/test/cool.rsยป(ยซ๐4ยป)โบ");
560 test_path!("โนยซ/test/cool.rsยป(ยซ4ยป๐)โบ");
561
562 // path, line, and column
563 test_path!("โนยซ/๐test/cool.rsยป:ยซ4ยป:ยซ2ยปโบ");
564 test_path!("โนยซ/test/cool.rsยป:ยซ4ยป:ยซ๐2ยปโบ");
565 test_path!("โนยซ/๐test/cool.rsยป(ยซ4ยป,ยซ2ยป)โบ");
566 test_path!("โนยซ/test/cool.rsยป(ยซ4ยป๐,ยซ2ยป)โบ");
567
568 // path, line, column, and ':' suffix
569 test_path!("โนยซ/๐test/cool.rsยป:ยซ4ยป:ยซ2ยปโบ:");
570 test_path!("โนยซ/test/cool.rsยป:ยซ4ยป:ยซ๐2ยปโบ:");
571 test_path!("โนยซ/๐test/cool.rsยป(ยซ4ยป,ยซ2ยป)โบ:");
572 test_path!("โนยซ/test/cool.rsยป(ยซ4ยป,ยซ2ยป๐)โบ:");
573 test_path!("โนยซ/๐test/cool.rsยป:(ยซ4ยป,ยซ2ยป)โบ:");
574 test_path!("โนยซ/test/cool.rsยป:(ยซ4ยป,ยซ2ยป๐)โบ:");
575 test_path!("โนยซ/๐test/cool.rsยป:(ยซ4ยป:ยซ2ยป)โบ:");
576 test_path!("โนยซ/test/cool.rsยป:(ยซ4ยป:ยซ2ยป๐)โบ:");
577 test_path!("/test/cool.rs:4:2๐:", "What is this?");
578 test_path!("/test/cool.rs(4,2)๐:", "What is this?");
579
580 // path, line, column, and description
581 test_path!("/test/cool.rs:4:2๐:Error!");
582 test_path!("/test/cool.rs:4:2:๐Error!");
583 test_path!("โนยซ/test/co๐ol.rsยป:ยซ4ยป:ยซ2ยปโบ:Error!");
584 test_path!("โนยซ/test/co๐ol.rsยป(ยซ4ยป,ยซ2ยป)โบ:Error!");
585
586 // Cargo output
587 test_path!(" Compiling Cool ๐(/test/Cool)");
588 test_path!(" Compiling Cool (โนยซ/๐test/Coolยปโบ)");
589 test_path!(" Compiling Cool (/test/Cool๐)");
590
591 // Python
592 test_path!("โนยซawe๐some.pyยปโบ");
593
594 test_path!(" โนF๐ile \"ยซ/awesome.pyยป\", line ยซ42ยปโบ: Wat?");
595 test_path!(" โนFile \"ยซ/awe๐some.pyยป\", line ยซ42ยปโบ");
596 test_path!(" โนFile \"ยซ/awesome.pyยป๐\", line ยซ42ยปโบ: Wat?");
597 test_path!(" โนFile \"ยซ/awesome.pyยป\", line ยซ4๐2ยปโบ");
598 }
599
600 #[test]
601 fn simple_with_descriptions() {
602 // path, line, column and description
603 test_path!("โนยซ/๐test/cool.rsยป:ยซ4ยป:ยซ2ยปโบ:ไพDescไพไพไพ");
604 test_path!("โนยซ/test/cool.rsยป:ยซ4ยป:ยซ๐2ยปโบ:ไพDescไพไพไพ");
605 test_path!("/test/cool.rs:4:2:ไพDescไพ๐ไพไพ");
606 test_path!("โนยซ/๐test/cool.rsยป(ยซ4ยป,ยซ2ยป)โบ:ไพDescไพไพไพ");
607 test_path!("โนยซ/test/cool.rsยป(ยซ4ยป๐,ยซ2ยป)โบ:ไพDescไพไพไพ");
608 test_path!("/test/cool.rs(4,2):ไพDescไพ๐ไพไพ");
609
610 // path, line, column and description w/extra colons
611 test_path!("โนยซ/๐test/cool.rsยป:ยซ4ยป:ยซ2ยปโบ::ไพDescไพไพไพ");
612 test_path!("โนยซ/test/cool.rsยป:ยซ4ยป:ยซ๐2ยปโบ::ไพDescไพไพไพ");
613 test_path!("/test/cool.rs:4:2::ไพDescไพ๐ไพไพ");
614 test_path!("โนยซ/๐test/cool.rsยป(ยซ4ยป,ยซ2ยป)โบ::ไพDescไพไพไพ");
615 test_path!("โนยซ/test/cool.rsยป(ยซ4ยป,ยซ2ยป๐)โบ::ไพDescไพไพไพ");
616 test_path!("/test/cool.rs(4,2)::ไพDescไพ๐ไพไพ");
617 }
618
619 #[test]
620 fn multiple_same_line() {
621 test_path!("โนยซ/๐test/cool.rsยปโบ /test/cool.rs");
622 test_path!("/test/cool.rs โนยซ/๐test/cool.rsยปโบ");
623
624 test_path!(
625 "โนยซ๐ฆ multiple_๐same_line ๐ฆยป ๐ฃยซ4ยป ๐๏ธยซ2ยปโบ: ๐ฆ multiple_same_line ๐ฆ ๐ฃ4 ๐๏ธ2:"
626 );
627 test_path!(
628 "๐ฆ multiple_same_line ๐ฆ ๐ฃ4 ๐๏ธ2 โนยซ๐ฆ multiple_๐same_line ๐ฆยป ๐ฃยซ4ยป ๐๏ธยซ2ยปโบ:"
629 );
630
631 // ls output (tab separated)
632 test_path!(
633 "โนยซCarg๐o.tomlยปโบ\t\texperiments\t\tnotebooks\t\trust-toolchain.toml\ttooling"
634 );
635 test_path!(
636 "Cargo.toml\t\tโนยซexper๐imentsยปโบ\t\tnotebooks\t\trust-toolchain.toml\ttooling"
637 );
638 test_path!(
639 "Cargo.toml\t\texperiments\t\tโนยซnote๐booksยปโบ\t\trust-toolchain.toml\ttooling"
640 );
641 test_path!(
642 "Cargo.toml\t\texperiments\t\tnotebooks\t\tโนยซrust-t๐oolchain.tomlยปโบ\ttooling"
643 );
644 test_path!(
645 "Cargo.toml\t\texperiments\t\tnotebooks\t\trust-toolchain.toml\tโนยซtoo๐lingยปโบ"
646 );
647 }
648
649 #[test]
650 fn colons_galore() {
651 test_path!("โนยซ/test/co๐ol.rsยป:ยซ4ยปโบ");
652 test_path!("โนยซ/test/co๐ol.rsยป:ยซ4ยปโบ:");
653 test_path!("โนยซ/test/co๐ol.rsยป:ยซ4ยป:ยซ2ยปโบ");
654 test_path!("โนยซ/test/co๐ol.rsยป:ยซ4ยป:ยซ2ยปโบ:");
655 test_path!("โนยซ/test/co๐ol.rsยป(ยซ1ยป)โบ");
656 test_path!("โนยซ/test/co๐ol.rsยป(ยซ1ยป)โบ:");
657 test_path!("โนยซ/test/co๐ol.rsยป(ยซ1ยป,ยซ618ยป)โบ");
658 test_path!("โนยซ/test/co๐ol.rsยป(ยซ1ยป,ยซ618ยป)โบ:");
659 test_path!("โนยซ/test/co๐ol.rsยป::ยซ42ยปโบ");
660 test_path!("โนยซ/test/co๐ol.rsยป::ยซ42ยปโบ:");
661 test_path!("โนยซ/test/co๐ol.rs:4:2ยป(ยซ1ยป,ยซ618ยป)โบ");
662 test_path!("โนยซ/test/co๐ol.rs:4:2ยป(ยซ1ยป,ยซ618ยป)โบ:");
663 test_path!("โนยซ/test/co๐ol.rsยป(ยซ1ยป,ยซ618ยป)โบ::");
664 }
665
666 #[test]
667 fn quotes_and_brackets() {
668 test_path!("\"โนยซ/test/co๐ol.rsยป:ยซ4ยปโบ\"");
669 test_path!("'โนยซ/test/co๐ol.rsยป:ยซ4ยปโบ'");
670 test_path!("`โนยซ/test/co๐ol.rsยป:ยซ4ยปโบ`");
671
672 test_path!("[โนยซ/test/co๐ol.rsยป:ยซ4ยปโบ]");
673 test_path!("(โนยซ/test/co๐ol.rsยป:ยซ4ยปโบ)");
674 test_path!("{โนยซ/test/co๐ol.rsยป:ยซ4ยปโบ}");
675 test_path!("<โนยซ/test/co๐ol.rsยป:ยซ4ยปโบ>");
676
677 test_path!("[\"โนยซ/test/co๐ol.rsยป:ยซ4ยปโบ\"]");
678 test_path!("'โนยซ(/test/co๐ol.rs:4)ยปโบ'");
679
680 test_path!("\"โนยซ/test/co๐ol.rsยป:ยซ4ยป:ยซ2ยปโบ\"");
681 test_path!("'โนยซ/test/co๐ol.rsยป:ยซ4ยป:ยซ2ยปโบ'");
682 test_path!("`โนยซ/test/co๐ol.rsยป:ยซ4ยป:ยซ2ยปโบ`");
683
684 test_path!("[โนยซ/test/co๐ol.rsยป:ยซ4ยป:ยซ2ยปโบ]");
685 test_path!("(โนยซ/test/co๐ol.rsยป:ยซ4ยป:ยซ2ยปโบ)");
686 test_path!("{โนยซ/test/co๐ol.rsยป:ยซ4ยป:ยซ2ยปโบ}");
687 test_path!("<โนยซ/test/co๐ol.rsยป:ยซ4ยป:ยซ2ยปโบ>");
688
689 test_path!("[\"โนยซ/test/co๐ol.rsยป:ยซ4ยป:ยซ2ยปโบ\"]");
690
691 test_path!("\"โนยซ/test/co๐ol.rsยป(ยซ4ยป)โบ\"");
692 test_path!("'โนยซ/test/co๐ol.rsยป(ยซ4ยป)โบ'");
693 test_path!("`โนยซ/test/co๐ol.rsยป(ยซ4ยป)โบ`");
694
695 test_path!("[โนยซ/test/co๐ol.rsยป(ยซ4ยป)โบ]");
696 test_path!("(โนยซ/test/co๐ol.rsยป(ยซ4ยป)โบ)");
697 test_path!("{โนยซ/test/co๐ol.rsยป(ยซ4ยป)โบ}");
698 test_path!("<โนยซ/test/co๐ol.rsยป(ยซ4ยป)โบ>");
699
700 test_path!("[\"โนยซ/test/co๐ol.rsยป(ยซ4ยป)โบ\"]");
701
702 test_path!("\"โนยซ/test/co๐ol.rsยป(ยซ4ยป,ยซ2ยป)โบ\"");
703 test_path!("'โนยซ/test/co๐ol.rsยป(ยซ4ยป,ยซ2ยป)โบ'");
704 test_path!("`โนยซ/test/co๐ol.rsยป(ยซ4ยป,ยซ2ยป)โบ`");
705
706 test_path!("[โนยซ/test/co๐ol.rsยป(ยซ4ยป,ยซ2ยป)โบ]");
707 test_path!("(โนยซ/test/co๐ol.rsยป(ยซ4ยป,ยซ2ยป)โบ)");
708 test_path!("{โนยซ/test/co๐ol.rsยป(ยซ4ยป,ยซ2ยป)โบ}");
709 test_path!("<โนยซ/test/co๐ol.rsยป(ยซ4ยป,ยซ2ยป)โบ>");
710
711 test_path!("[\"โนยซ/test/co๐ol.rsยป(ยซ4ยป,ยซ2ยป)โบ\"]");
712
713 // Imbalanced
714 test_path!("([โนยซ/test/co๐ol.rsยป:ยซ4ยปโบ] was here...)");
715 test_path!("[Here's <โนยซ/test/co๐ol.rsยป:ยซ4ยปโบ>]");
716 test_path!("('โนยซ/test/co๐ol.rsยป:ยซ4ยปโบ' was here...)");
717 test_path!("[Here's `โนยซ/test/co๐ol.rsยป:ยซ4ยปโบ`]");
718 }
719
720 #[test]
721 fn trailing_punctuation() {
722 test_path!("โนยซ/test/co๐ol.rsยปโบ:,..");
723 test_path!("/test/cool.rs:,๐..");
724 test_path!("โนยซ/test/co๐ol.rsยป:ยซ4ยปโบ:,");
725 test_path!("/test/cool.rs:4:๐,");
726 test_path!("[\"โนยซ/test/co๐ol.rsยป:ยซ4ยปโบ\"]:,");
727 test_path!("'โนยซ(/test/co๐ol.rs:4),,ยปโบ'..");
728 test_path!("('โนยซ/test/co๐ol.rsยป:ยซ4ยปโบ'::: was here...)");
729 test_path!("[Here's <โนยซ/test/co๐ol.rsยป:ยซ4ยปโบ>]::: ");
730 }
731
732 #[test]
733 fn word_wide_chars() {
734 // Rust paths
735 test_path!("โนยซ/๐ไพ/cool.rsยปโบ");
736 test_path!("โนยซ/ไพ๐/cool.rsยปโบ");
737 test_path!("โนยซ/ไพ/cool.rsยป:ยซ๐4ยปโบ");
738 test_path!("โนยซ/ไพ/cool.rsยป:ยซ4ยป:ยซ๐2ยปโบ");
739
740 // Cargo output
741 test_path!(" Compiling Cool (โนยซ/๐ไพ/Coolยปโบ)");
742 test_path!(" Compiling Cool (โนยซ/ไพ๐/Coolยปโบ)");
743
744 test_path!(" Compiling Cool (โนยซ/๐ไพ/Cool Spacesยปโบ)");
745 test_path!(" Compiling Cool (โนยซ/ไพ๐/Cool Spacesยปโบ)");
746 test_path!(" Compiling Cool (โนยซ/๐ไพ/Cool Spacesยป:ยซ4ยป:ยซ2ยปโบ)");
747 test_path!(" Compiling Cool (โนยซ/ไพ๐/Cool Spacesยป(ยซ4ยป,ยซ2ยป)โบ)");
748
749 test_path!(" --> โนยซ/๐ไพ/Cool Spacesยปโบ");
750 test_path!(" ::: โนยซ/ไพ๐/Cool Spacesยปโบ");
751 test_path!(" --> โนยซ/๐ไพ/Cool Spacesยป:ยซ4ยป:ยซ2ยปโบ");
752 test_path!(" ::: โนยซ/ไพ๐/Cool Spacesยป(ยซ4ยป,ยซ2ยป)โบ");
753 test_path!(" panicked at โนยซ/๐ไพ/Cool Spacesยป:ยซ4ยป:ยซ2ยปโบ:");
754 test_path!(" panicked at โนยซ/ไพ๐/Cool Spacesยป(ยซ4ยป,ยซ2ยป)โบ:");
755 test_path!(" at โนยซ/๐ไพ/Cool Spacesยป:ยซ4ยป:ยซ2ยปโบ");
756 test_path!(" at โนยซ/ไพ๐/Cool Spacesยป(ยซ4ยป,ยซ2ยป)โบ");
757
758 // Python
759 test_path!("โนยซ๐ไพwesome.pyยปโบ");
760 test_path!("โนยซไพ๐wesome.pyยปโบ");
761 test_path!(" โนFile \"ยซ/๐ไพwesome.pyยป\", line ยซ42ยปโบ: Wat?");
762 test_path!(" โนFile \"ยซ/ไพ๐wesome.pyยป\", line ยซ42ยปโบ: Wat?");
763 }
764
765 #[test]
766 fn non_word_wide_chars() {
767 // Mojo diagnostic message
768 test_path!(" โนFile \"ยซ/awe๐some.๐ฅยป\", line ยซ42ยปโบ: Wat?");
769 test_path!(" โนFile \"ยซ/awesome๐.๐ฅยป\", line ยซ42ยปโบ: Wat?");
770 test_path!(" โนFile \"ยซ/awesome.๐๐ฅยป\", line ยซ42ยปโบ: Wat?");
771 test_path!(" โนFile \"ยซ/awesome.๐ฅ๐ยป\", line ยซ42ยปโบ: Wat?");
772 }
773
774 /// These likely rise to the level of being worth fixing.
775 mod issues {
776 #[test]
777 // <https://github.com/alacritty/alacritty/issues/8586>
778 fn issue_alacritty_8586() {
779 // Rust paths
780 test_path!("โนยซ/๐ไพ/cool.rsยปโบ");
781 test_path!("โนยซ/ไพ๐/cool.rsยปโบ");
782 test_path!("โนยซ/ไพ/cool.rsยป:ยซ๐4ยปโบ");
783 test_path!("โนยซ/ไพ/cool.rsยป:ยซ4ยป:ยซ๐2ยปโบ");
784
785 // Cargo output
786 test_path!(" Compiling Cool (โนยซ/๐ไพ/Coolยปโบ)");
787 test_path!(" Compiling Cool (โนยซ/ไพ๐/Coolยปโบ)");
788
789 // Python
790 test_path!("โนยซ๐ไพwesome.pyยปโบ");
791 test_path!("โนยซไพ๐wesome.pyยปโบ");
792 test_path!(" โนFile \"ยซ/๐ไพwesome.pyยป\", line ยซ42ยปโบ: Wat?");
793 test_path!(" โนFile \"ยซ/ไพ๐wesome.pyยป\", line ยซ42ยปโบ: Wat?");
794 }
795
796 #[test]
797 // <https://github.com/zed-industries/zed/issues/12338>
798 fn issue_12338_regex() {
799 // Issue #12338
800 test_path!(".rw-r--r-- 0 staff 05-27 14:03 โนยซ'test file ๐1.txt'ยปโบ");
801 test_path!(".rw-r--r-- 0 staff 05-27 14:03 โนยซ๐'test file 1.txt'ยปโบ");
802 }
803
804 #[test]
805 // <https://github.com/zed-industries/zed/issues/12338>
806 fn issue_12338() {
807 // Issue #12338
808 test_path!(".rw-r--r-- 0 staff 05-27 14:03 โนยซtest๐ใ2.txtยปโบ");
809 test_path!(".rw-r--r-- 0 staff 05-27 14:03 โนยซtestใ๐2.txtยปโบ");
810 test_path!(".rw-r--r-- 0 staff 05-27 14:03 โนยซtest๐ใ3.txtยปโบ");
811 test_path!(".rw-r--r-- 0 staff 05-27 14:03 โนยซtestใ๐3.txtยปโบ");
812
813 // Rust paths
814 test_path!("โนยซ/๐๐/๐ฆ.rsยปโบ");
815 test_path!("โนยซ/๐๐/๐ฆ.rsยปโบ");
816 test_path!("โนยซ/๐/๐๐ฆ.rsยป:ยซ4ยปโบ");
817 test_path!("โนยซ/๐/๐ฆ๐.rsยป:ยซ4ยป:ยซ2ยปโบ");
818
819 // Cargo output
820 test_path!(" Compiling Cool (โนยซ/๐๐/Coolยปโบ)");
821 test_path!(" Compiling Cool (โนยซ/๐๐/Coolยปโบ)");
822
823 // Python
824 test_path!("โนยซ๐๐wesome.pyยปโบ");
825 test_path!("โนยซ๐๐wesome.pyยปโบ");
826 test_path!(" โนFile \"ยซ/๐๐wesome.pyยป\", line ยซ42ยปโบ: Wat?");
827 test_path!(" โนFile \"ยซ/๐๐wesome.pyยป\", line ยซ42ยปโบ: Wat?");
828
829 // Mojo
830 test_path!("โนยซ/awe๐some.๐ฅยปโบ is some good Mojo!");
831 test_path!("โนยซ/awesome๐.๐ฅยปโบ is some good Mojo!");
832 test_path!("โนยซ/awesome.๐๐ฅยปโบ is some good Mojo!");
833 test_path!("โนยซ/awesome.๐ฅ๐ยปโบ is some good Mojo!");
834 test_path!(" โนFile \"ยซ/๐๐wesome.๐ฅยป\", line ยซ42ยปโบ: Wat?");
835 test_path!(" โนFile \"ยซ/๐๐wesome.๐ฅยป\", line ยซ42ยปโบ: Wat?");
836 }
837
838 #[test]
839 // <https://github.com/zed-industries/zed/issues/40202>
840 fn issue_40202() {
841 // Elixir
842 test_path!("[โนยซlib/blitz_apex_๐server/stats/aggregate_rank_stats.exยป:ยซ35ยปโบ: BlitzApexServer.Stats.AggregateRankStats.update/2]
843 1 #=> 1");
844 }
845
846 #[test]
847 // <https://github.com/zed-industries/zed/issues/28194>
848 fn issue_28194() {
849 test_path!(
850 "โนยซtest/c๐ontrollers/template_items_controller_test.rbยป:ยซ20ยปโบ:in 'block (2 levels) in <class:TemplateItemsControllerTest>'"
851 );
852 test_path!(
853 "test/controllers/template_items_controller_test.rb:19:i๐n 'block in <class:TemplateItemsControllerTest>'"
854 );
855 }
856
857 #[test]
858 #[cfg_attr(
859 not(target_os = "windows"),
860 should_panic(
861 expected = "Path = ยซ/test/cool.rs:4:NotDescยป, at grid cells (0, 1)..=(7, 2)"
862 )
863 )]
864 #[cfg_attr(
865 target_os = "windows",
866 should_panic(
867 expected = r#"Path = ยซC:\\test\\cool.rs:4:NotDescยป, at grid cells (0, 1)..=(8, 1)"#
868 )
869 )]
870 // PathWithPosition::parse_str considers "/test/co๐ol.rs:4:NotDesc" invalid input, but
871 // still succeeds and truncates the part after the position. Ideally this would be
872 // parsed as the path "/test/co๐ol.rs:4:NotDesc" with no position.
873 fn path_with_position_parse_str() {
874 test_path!("`โนยซ/test/co๐ol.rs:4:NotDescยปโบ`");
875 test_path!("<โนยซ/test/co๐ol.rs:4:NotDescยปโบ>");
876
877 test_path!("'โนยซ(/test/co๐ol.rs:4:2)ยปโบ'");
878 test_path!("'โนยซ(/test/co๐ol.rs(4))ยปโบ'");
879 test_path!("'โนยซ(/test/co๐ol.rs(4,2))ยปโบ'");
880 }
881 }
882
883 /// Minor issues arguably not important enough to fix/workaround...
884 mod nits {
885 #[test]
886 fn alacritty_bugs_with_two_columns() {
887 test_path!("โนยซ/๐test/cool.rsยป(ยซ4ยป)โบ");
888 test_path!("โนยซ/test/cool.rsยป(ยซ๐4ยป)โบ");
889 test_path!("โนยซ/test/cool.rsยป(ยซ4ยป,ยซ๐2ยป)โบ");
890
891 // Python
892 test_path!("โนยซawe๐some.pyยปโบ");
893 }
894
895 #[test]
896 #[cfg_attr(
897 not(target_os = "windows"),
898 should_panic(
899 expected = "Path = ยซ/test/cool.rsยป, line = 1, at grid cells (0, 0)..=(9, 0)"
900 )
901 )]
902 #[cfg_attr(
903 target_os = "windows",
904 should_panic(
905 expected = r#"Path = ยซC:\\test\\cool.rsยป, line = 1, at grid cells (0, 0)..=(9, 2)"#
906 )
907 )]
908 fn invalid_row_column_should_be_part_of_path() {
909 test_path!("โนยซ/๐test/cool.rs:1:618033988749ยปโบ");
910 test_path!("โนยซ/๐test/cool.rs(1,618033988749)ยปโบ");
911 }
912
913 #[test]
914 #[cfg_attr(
915 not(target_os = "windows"),
916 should_panic(expected = "Path = ยซ/te:st/co:ol.r:s:4:2::::::ยป")
917 )]
918 #[cfg_attr(
919 target_os = "windows",
920 should_panic(expected = r#"Path = ยซC:\\te:st\\co:ol.r:s:4:2::::::ยป"#)
921 )]
922 fn many_trailing_colons_should_be_parsed_as_part_of_the_path() {
923 test_path!("โนยซ/te:st/๐co:ol.r:s:4:2::::::ยปโบ");
924 test_path!("/test/cool.rs:::๐:");
925 }
926 }
927
928 mod windows {
929 // Lots of fun to be had with long file paths (verbatim) and UNC paths on Windows.
930 // See <https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation>
931 // See <https://users.rust-lang.org/t/understanding-windows-paths/58583>
932 // See <https://github.com/rust-lang/cargo/issues/13919>
933
934 #[test]
935 fn default_prompts() {
936 // Windows command prompt
937 test_path!(r#"โนยซC:\Users\someone\๐testยปโบ>"#);
938 test_path!(r#"C:\Users\someone\test๐>"#);
939
940 // Windows PowerShell
941 test_path!(r#"PS โนยซC:\Users\someone\๐test\cool.rsยปโบ>"#);
942 test_path!(r#"PS C:\Users\someone\test\cool.rs๐>"#);
943 }
944
945 #[test]
946 fn unc() {
947 test_path!(r#"โนยซ\\server\share\๐test\cool.rsยปโบ"#);
948 test_path!(r#"โนยซ\\server\share\test\cool๐.rsยปโบ"#);
949 }
950
951 mod issues {
952 #[test]
953 fn issue_verbatim() {
954 test_path!(r#"โนยซ\\?\C:\๐test\cool.rsยปโบ"#);
955 test_path!(r#"โนยซ\\?\C:\test\cool๐.rsยปโบ"#);
956 }
957
958 #[test]
959 fn issue_verbatim_unc() {
960 test_path!(r#"โนยซ\\?\UNC\server\share\๐test\cool.rsยปโบ"#);
961 test_path!(r#"โนยซ\\?\UNC\server\share\test\cool๐.rsยปโบ"#);
962 }
963 }
964 }
965
966 mod perf {
967 use super::super::*;
968 use crate::TerminalSettings;
969 use alacritty_terminal::{
970 event::VoidListener,
971 grid::Dimensions,
972 index::{Column, Point as AlacPoint},
973 term::test::mock_term,
974 term::{Term, search::Match},
975 };
976 use settings::{self, Settings, SettingsContent};
977 use std::{cell::RefCell, rc::Rc};
978 use util_macros::perf;
979
980 fn build_test_term(line: &str) -> (Term<VoidListener>, AlacPoint) {
981 let content = line.repeat(500);
982 let term = mock_term(&content);
983 let point = AlacPoint::new(
984 term.grid().bottommost_line() - 1,
985 Column(term.grid().last_column().0 / 2),
986 );
987
988 (term, point)
989 }
990
991 #[perf]
992 pub fn cargo_hyperlink_benchmark() {
993 const LINE: &str = " Compiling terminal v0.1.0 (/Hyperlinks/Bench/Source/zed-hyperlinks/crates/terminal)\r\n";
994 thread_local! {
995 static TEST_TERM_AND_POINT: (Term<VoidListener>, AlacPoint) =
996 build_test_term(LINE);
997 }
998 TEST_TERM_AND_POINT.with(|(term, point)| {
999 assert!(
1000 find_from_grid_point_bench(term, *point).is_some(),
1001 "Hyperlink should have been found"
1002 );
1003 });
1004 }
1005
1006 #[perf]
1007 pub fn rust_hyperlink_benchmark() {
1008 const LINE: &str = " --> /Hyperlinks/Bench/Source/zed-hyperlinks/crates/terminal/terminal.rs:1000:42\r\n";
1009 thread_local! {
1010 static TEST_TERM_AND_POINT: (Term<VoidListener>, AlacPoint) =
1011 build_test_term(LINE);
1012 }
1013 TEST_TERM_AND_POINT.with(|(term, point)| {
1014 assert!(
1015 find_from_grid_point_bench(term, *point).is_some(),
1016 "Hyperlink should have been found"
1017 );
1018 });
1019 }
1020
1021 #[perf]
1022 pub fn ls_hyperlink_benchmark() {
1023 const LINE: &str = "Cargo.toml experiments notebooks rust-toolchain.toml tooling\r\n";
1024 thread_local! {
1025 static TEST_TERM_AND_POINT: (Term<VoidListener>, AlacPoint) =
1026 build_test_term(LINE);
1027 }
1028 TEST_TERM_AND_POINT.with(|(term, point)| {
1029 assert!(
1030 find_from_grid_point_bench(term, *point).is_some(),
1031 "Hyperlink should have been found"
1032 );
1033 });
1034 }
1035
1036 pub fn find_from_grid_point_bench(
1037 term: &Term<VoidListener>,
1038 point: AlacPoint,
1039 ) -> Option<(String, bool, Match)> {
1040 const PATH_HYPERLINK_TIMEOUT_MS: u64 = 1000;
1041
1042 thread_local! {
1043 static TEST_REGEX_SEARCHES: RefCell<RegexSearches> =
1044 RefCell::new({
1045 let default_settings_content: Rc<SettingsContent> =
1046 settings::parse_json_with_comments(&settings::default_settings())
1047 .unwrap();
1048 let default_terminal_settings =
1049 TerminalSettings::from_settings(&default_settings_content);
1050
1051 RegexSearches::new(
1052 &default_terminal_settings.path_hyperlink_regexes,
1053 PATH_HYPERLINK_TIMEOUT_MS
1054 )
1055 });
1056 }
1057
1058 TEST_REGEX_SEARCHES.with(|regex_searches| {
1059 find_from_grid_point(&term, point, &mut regex_searches.borrow_mut())
1060 })
1061 }
1062 }
1063 }
1064
1065 mod file_iri {
1066 // File IRIs have a ton of use cases, most of which we currently do not support. A few of
1067 // those cases are documented here as tests which are expected to fail.
1068 // See https://en.wikipedia.org/wiki/File_URI_scheme
1069
1070 /// [**`cโ, cโ, โฆ, cโ;`**]โโโ := use specified terminal widths of `cโ, cโ, โฆ, cโ` **columns**
1071 /// (defaults to `3, longest_line_cells / 2, longest_line_cells + 1;`)
1072 ///
1073 macro_rules! test_file_iri {
1074 ($file_iri:literal) => { { test_hyperlink!(concat!("โนยซ๐", $file_iri, "ยปโบ"); FileIri) } };
1075 }
1076
1077 #[cfg(not(target_os = "windows"))]
1078 #[test]
1079 fn absolute_file_iri() {
1080 test_file_iri!("file:///test/cool/index.rs");
1081 test_file_iri!("file:///test/cool/");
1082 }
1083
1084 mod issues {
1085 #[cfg(not(target_os = "windows"))]
1086 #[test]
1087 #[should_panic(expected = "Path = ยซ/test/แฟฌฯฮดฮฟฯ/ยป, at grid cells (0, 0)..=(15, 1)")]
1088 fn issue_file_iri_with_percent_encoded_characters() {
1089 // Non-space characters
1090 // file:///test/แฟฌฯฮดฮฟฯ/
1091 test_file_iri!("file:///test/%E1%BF%AC%CF%8C%CE%B4%CE%BF%CF%82/"); // URI
1092
1093 // Spaces
1094 test_file_iri!("file:///te%20st/co%20ol/index.rs");
1095 test_file_iri!("file:///te%20st/co%20ol/");
1096 }
1097 }
1098
1099 #[cfg(target_os = "windows")]
1100 mod windows {
1101 mod issues {
1102 // The test uses Url::to_file_path(), but it seems that the Url crate doesn't
1103 // support relative file IRIs.
1104 #[test]
1105 #[should_panic(
1106 expected = r#"Failed to interpret file IRI `file:/test/cool/index.rs` as a path"#
1107 )]
1108 fn issue_relative_file_iri() {
1109 test_file_iri!("file:/test/cool/index.rs");
1110 test_file_iri!("file:/test/cool/");
1111 }
1112
1113 // See https://en.wikipedia.org/wiki/File_URI_scheme
1114 // https://github.com/zed-industries/zed/issues/39189
1115 #[test]
1116 #[should_panic(
1117 expected = r#"Path = ยซC:\\test\\cool\\index.rsยป, at grid cells (0, 0)..=(9, 1)"#
1118 )]
1119 fn issue_39189() {
1120 test_file_iri!("file:///C:/test/cool/index.rs");
1121 test_file_iri!("file:///C:/test/cool/");
1122 }
1123
1124 #[test]
1125 #[should_panic(
1126 expected = r#"Path = ยซC:\\test\\แฟฌฯฮดฮฟฯ\\ยป, at grid cells (0, 0)..=(16, 1)"#
1127 )]
1128 fn issue_file_iri_with_percent_encoded_characters() {
1129 // Non-space characters
1130 // file:///test/แฟฌฯฮดฮฟฯ/
1131 test_file_iri!("file:///C:/test/%E1%BF%AC%CF%8C%CE%B4%CE%BF%CF%82/"); // URI
1132
1133 // Spaces
1134 test_file_iri!("file:///C:/te%20st/co%20ol/index.rs");
1135 test_file_iri!("file:///C:/te%20st/co%20ol/");
1136 }
1137 }
1138 }
1139 }
1140
1141 mod iri {
1142 /// [**`cโ, cโ, โฆ, cโ;`**]โโโ := use specified terminal widths of `cโ, cโ, โฆ, cโ` **columns**
1143 /// (defaults to `3, longest_line_cells / 2, longest_line_cells + 1;`)
1144 ///
1145 macro_rules! test_iri {
1146 ($iri:literal) => { { test_hyperlink!(concat!("โนยซ๐", $iri, "ยปโบ"); Iri) } };
1147 }
1148
1149 #[test]
1150 fn simple() {
1151 // In the order they appear in URL_REGEX, except 'file://' which is treated as a path
1152 test_iri!("ipfs://test/cool.ipfs");
1153 test_iri!("ipns://test/cool.ipns");
1154 test_iri!("magnet://test/cool.git");
1155 test_iri!("mailto:someone@somewhere.here");
1156 test_iri!("gemini://somewhere.here");
1157 test_iri!("gopher://somewhere.here");
1158 test_iri!("http://test/cool/index.html");
1159 test_iri!("http://10.10.10.10:1111/cool.html");
1160 test_iri!("http://test/cool/index.html?amazing=1");
1161 test_iri!("http://test/cool/index.html#right%20here");
1162 test_iri!("http://test/cool/index.html?amazing=1#right%20here");
1163 test_iri!("https://test/cool/index.html");
1164 test_iri!("https://10.10.10.10:1111/cool.html");
1165 test_iri!("https://test/cool/index.html?amazing=1");
1166 test_iri!("https://test/cool/index.html#right%20here");
1167 test_iri!("https://test/cool/index.html?amazing=1#right%20here");
1168 test_iri!("news://test/cool.news");
1169 test_iri!("git://test/cool.git");
1170 test_iri!("ssh://user@somewhere.over.here:12345/test/cool.git");
1171 test_iri!("ftp://test/cool.ftp");
1172 }
1173
1174 #[test]
1175 fn wide_chars() {
1176 // In the order they appear in URL_REGEX, except 'file://' which is treated as a path
1177 test_iri!("ipfs://ไพ๐๐ฆ/cool.ipfs");
1178 test_iri!("ipns://ไพ๐๐ฆ/cool.ipns");
1179 test_iri!("magnet://ไพ๐๐ฆ/cool.git");
1180 test_iri!("mailto:someone@somewhere.here");
1181 test_iri!("gemini://somewhere.here");
1182 test_iri!("gopher://somewhere.here");
1183 test_iri!("http://ไพ๐๐ฆ/cool/index.html");
1184 test_iri!("http://10.10.10.10:1111/cool.html");
1185 test_iri!("http://ไพ๐๐ฆ/cool/index.html?amazing=1");
1186 test_iri!("http://ไพ๐๐ฆ/cool/index.html#right%20here");
1187 test_iri!("http://ไพ๐๐ฆ/cool/index.html?amazing=1#right%20here");
1188 test_iri!("https://ไพ๐๐ฆ/cool/index.html");
1189 test_iri!("https://10.10.10.10:1111/cool.html");
1190 test_iri!("https://ไพ๐๐ฆ/cool/index.html?amazing=1");
1191 test_iri!("https://ไพ๐๐ฆ/cool/index.html#right%20here");
1192 test_iri!("https://ไพ๐๐ฆ/cool/index.html?amazing=1#right%20here");
1193 test_iri!("news://ไพ๐๐ฆ/cool.news");
1194 test_iri!("git://ไพ/cool.git");
1195 test_iri!("ssh://user@somewhere.over.here:12345/ไพ๐๐ฆ/cool.git");
1196 test_iri!("ftp://ไพ๐๐ฆ/cool.ftp");
1197 }
1198
1199 // There are likely more tests needed for IRI vs URI
1200 #[test]
1201 fn iris() {
1202 // These refer to the same location, see example here:
1203 // <https://en.wikipedia.org/wiki/Internationalized_Resource_Identifier#Compatibility>
1204 test_iri!("https://en.wiktionary.org/wiki/แฟฌฯฮดฮฟฯ"); // IRI
1205 test_iri!("https://en.wiktionary.org/wiki/%E1%BF%AC%CF%8C%CE%B4%CE%BF%CF%82"); // URI
1206 }
1207
1208 #[test]
1209 #[should_panic(expected = "Expected a path, but was a iri")]
1210 fn file_is_a_path() {
1211 test_iri!("file://test/cool/index.rs");
1212 }
1213 }
1214
1215 #[derive(Debug, PartialEq)]
1216 enum HyperlinkKind {
1217 FileIri,
1218 Iri,
1219 Path,
1220 }
1221
1222 struct ExpectedHyperlink {
1223 hovered_grid_point: AlacPoint,
1224 hovered_char: char,
1225 hyperlink_kind: HyperlinkKind,
1226 iri_or_path: String,
1227 row: Option<u32>,
1228 column: Option<u32>,
1229 hyperlink_match: RangeInclusive<AlacPoint>,
1230 }
1231
1232 /// Converts to Windows style paths on Windows, like path!(), but at runtime for improved test
1233 /// readability.
1234 fn build_term_from_test_lines<'a>(
1235 hyperlink_kind: HyperlinkKind,
1236 term_size: TermSize,
1237 test_lines: impl Iterator<Item = &'a str>,
1238 ) -> (Term<VoidListener>, ExpectedHyperlink) {
1239 #[derive(Default, Eq, PartialEq)]
1240 enum HoveredState {
1241 #[default]
1242 HoveredScan,
1243 HoveredNextChar,
1244 Done,
1245 }
1246
1247 #[derive(Default, Eq, PartialEq)]
1248 enum MatchState {
1249 #[default]
1250 MatchScan,
1251 MatchNextChar,
1252 Match(AlacPoint),
1253 Done,
1254 }
1255
1256 #[derive(Default, Eq, PartialEq)]
1257 enum CapturesState {
1258 #[default]
1259 PathScan,
1260 PathNextChar,
1261 Path(AlacPoint),
1262 RowScan,
1263 Row(String),
1264 ColumnScan,
1265 Column(String),
1266 Done,
1267 }
1268
1269 fn prev_input_point_from_term(term: &Term<VoidListener>) -> AlacPoint {
1270 let grid = term.grid();
1271 let cursor = &grid.cursor;
1272 let mut point = cursor.point;
1273
1274 if !cursor.input_needs_wrap {
1275 point = point.sub(term, Boundary::Grid, 1);
1276 }
1277
1278 if grid.index(point).flags.contains(Flags::WIDE_CHAR_SPACER) {
1279 point.column -= 1;
1280 }
1281
1282 point
1283 }
1284
1285 fn end_point_from_prev_input_point(
1286 term: &Term<VoidListener>,
1287 prev_input_point: AlacPoint,
1288 ) -> AlacPoint {
1289 if term
1290 .grid()
1291 .index(prev_input_point)
1292 .flags
1293 .contains(Flags::WIDE_CHAR)
1294 {
1295 prev_input_point.add(term, Boundary::Grid, 1)
1296 } else {
1297 prev_input_point
1298 }
1299 }
1300
1301 fn process_input(term: &mut Term<VoidListener>, c: char) {
1302 match c {
1303 '\t' => term.put_tab(1),
1304 c @ _ => term.input(c),
1305 }
1306 }
1307
1308 let mut hovered_grid_point: Option<AlacPoint> = None;
1309 let mut hyperlink_match = AlacPoint::default()..=AlacPoint::default();
1310 let mut iri_or_path = String::default();
1311 let mut row = None;
1312 let mut column = None;
1313 let mut prev_input_point = AlacPoint::default();
1314 let mut hovered_state = HoveredState::default();
1315 let mut match_state = MatchState::default();
1316 let mut captures_state = CapturesState::default();
1317 let mut term = Term::new(Config::default(), &term_size, VoidListener);
1318
1319 for text in test_lines {
1320 let chars: Box<dyn Iterator<Item = char>> =
1321 if cfg!(windows) && hyperlink_kind == HyperlinkKind::Path {
1322 Box::new(text.chars().map(|c| if c == '/' { '\\' } else { c })) as _
1323 } else {
1324 Box::new(text.chars()) as _
1325 };
1326 let mut chars = chars.peekable();
1327 while let Some(c) = chars.next() {
1328 match c {
1329 '๐' => {
1330 hovered_state = HoveredState::HoveredNextChar;
1331 }
1332 '๐' => {
1333 hovered_grid_point = Some(prev_input_point.add(&term, Boundary::Grid, 1));
1334 }
1335 'ยซ' | 'ยป' => {
1336 captures_state = match captures_state {
1337 CapturesState::PathScan => CapturesState::PathNextChar,
1338 CapturesState::PathNextChar => {
1339 panic!("Should have been handled by char input")
1340 }
1341 CapturesState::Path(start_point) => {
1342 iri_or_path = term.bounds_to_string(
1343 start_point,
1344 end_point_from_prev_input_point(&term, prev_input_point),
1345 );
1346 CapturesState::RowScan
1347 }
1348 CapturesState::RowScan => CapturesState::Row(String::new()),
1349 CapturesState::Row(number) => {
1350 row = Some(number.parse::<u32>().unwrap());
1351 CapturesState::ColumnScan
1352 }
1353 CapturesState::ColumnScan => CapturesState::Column(String::new()),
1354 CapturesState::Column(number) => {
1355 column = Some(number.parse::<u32>().unwrap());
1356 CapturesState::Done
1357 }
1358 CapturesState::Done => {
1359 panic!("Extra 'ยซ', 'ยป'")
1360 }
1361 }
1362 }
1363 'โน' | 'โบ' => {
1364 match_state = match match_state {
1365 MatchState::MatchScan => MatchState::MatchNextChar,
1366 MatchState::MatchNextChar => {
1367 panic!("Should have been handled by char input")
1368 }
1369 MatchState::Match(start_point) => {
1370 hyperlink_match = start_point
1371 ..=end_point_from_prev_input_point(&term, prev_input_point);
1372 MatchState::Done
1373 }
1374 MatchState::Done => {
1375 panic!("Extra 'โน', 'โบ'")
1376 }
1377 }
1378 }
1379 _ => {
1380 if let CapturesState::Row(number) | CapturesState::Column(number) =
1381 &mut captures_state
1382 {
1383 number.push(c)
1384 }
1385
1386 let is_windows_abs_path_start = captures_state
1387 == CapturesState::PathNextChar
1388 && cfg!(windows)
1389 && hyperlink_kind == HyperlinkKind::Path
1390 && c == '\\'
1391 && chars.peek().is_some_and(|c| *c != '\\');
1392
1393 if is_windows_abs_path_start {
1394 // Convert Unix abs path start into Windows abs path start so that the
1395 // same test can be used for both OSes.
1396 term.input('C');
1397 prev_input_point = prev_input_point_from_term(&term);
1398 term.input(':');
1399 process_input(&mut term, c);
1400 } else {
1401 process_input(&mut term, c);
1402 prev_input_point = prev_input_point_from_term(&term);
1403 }
1404
1405 if hovered_state == HoveredState::HoveredNextChar {
1406 hovered_grid_point = Some(prev_input_point);
1407 hovered_state = HoveredState::Done;
1408 }
1409 if captures_state == CapturesState::PathNextChar {
1410 captures_state = CapturesState::Path(prev_input_point);
1411 }
1412 if match_state == MatchState::MatchNextChar {
1413 match_state = MatchState::Match(prev_input_point);
1414 }
1415 }
1416 }
1417 }
1418 term.move_down_and_cr(1);
1419 }
1420
1421 if hyperlink_kind == HyperlinkKind::FileIri {
1422 let Ok(url) = Url::parse(&iri_or_path) else {
1423 panic!("Failed to parse file IRI `{iri_or_path}`");
1424 };
1425 let Ok(path) = url.to_file_path() else {
1426 panic!("Failed to interpret file IRI `{iri_or_path}` as a path");
1427 };
1428 iri_or_path = path.to_string_lossy().into_owned();
1429 }
1430
1431 let hovered_grid_point = hovered_grid_point.expect("Missing hovered point (๐ or ๐)");
1432 let hovered_char = term.grid().index(hovered_grid_point).c;
1433 (
1434 term,
1435 ExpectedHyperlink {
1436 hovered_grid_point,
1437 hovered_char,
1438 hyperlink_kind,
1439 iri_or_path,
1440 row,
1441 column,
1442 hyperlink_match,
1443 },
1444 )
1445 }
1446
1447 fn line_cells_count(line: &str) -> usize {
1448 // This avoids taking a dependency on the unicode-width crate
1449 fn width(c: char) -> usize {
1450 match c {
1451 // Fullwidth unicode characters used in tests
1452 'ไพ' | '๐' | '๐ฆ' | '๐ฅ' => 2,
1453 '\t' => 8, // it's really 0-8, use the max always
1454 _ => 1,
1455 }
1456 }
1457 const CONTROL_CHARS: &str = "โนยซ๐๐ยปโบ";
1458 line.chars()
1459 .filter(|c| !CONTROL_CHARS.contains(*c))
1460 .map(width)
1461 .sum::<usize>()
1462 }
1463
1464 struct CheckHyperlinkMatch<'a> {
1465 term: &'a Term<VoidListener>,
1466 expected_hyperlink: &'a ExpectedHyperlink,
1467 source_location: &'a str,
1468 }
1469
1470 impl<'a> CheckHyperlinkMatch<'a> {
1471 fn new(
1472 term: &'a Term<VoidListener>,
1473 expected_hyperlink: &'a ExpectedHyperlink,
1474 source_location: &'a str,
1475 ) -> Self {
1476 Self {
1477 term,
1478 expected_hyperlink,
1479 source_location,
1480 }
1481 }
1482
1483 fn check_path_with_position_and_match(
1484 &self,
1485 path_with_position: PathWithPosition,
1486 hyperlink_match: &Match,
1487 ) {
1488 let format_path_with_position_and_match =
1489 |path_with_position: &PathWithPosition, hyperlink_match: &Match| {
1490 let mut result =
1491 format!("Path = ยซ{}ยป", &path_with_position.path.to_string_lossy());
1492 if let Some(row) = path_with_position.row {
1493 result += &format!(", line = {row}");
1494 if let Some(column) = path_with_position.column {
1495 result += &format!(", column = {column}");
1496 }
1497 }
1498
1499 result += &format!(
1500 ", at grid cells {}",
1501 Self::format_hyperlink_match(hyperlink_match)
1502 );
1503 result
1504 };
1505
1506 assert_ne!(
1507 self.expected_hyperlink.hyperlink_kind,
1508 HyperlinkKind::Iri,
1509 "\n at {}\nExpected a path, but was a iri:\n{}",
1510 self.source_location,
1511 self.format_renderable_content()
1512 );
1513
1514 assert_eq!(
1515 format_path_with_position_and_match(
1516 &PathWithPosition {
1517 path: PathBuf::from(self.expected_hyperlink.iri_or_path.clone()),
1518 row: self.expected_hyperlink.row,
1519 column: self.expected_hyperlink.column
1520 },
1521 &self.expected_hyperlink.hyperlink_match
1522 ),
1523 format_path_with_position_and_match(&path_with_position, hyperlink_match),
1524 "\n at {}:\n{}",
1525 self.source_location,
1526 self.format_renderable_content()
1527 );
1528 }
1529
1530 fn check_iri_and_match(&self, iri: String, hyperlink_match: &Match) {
1531 let format_iri_and_match = |iri: &String, hyperlink_match: &Match| {
1532 format!(
1533 "Url = ยซ{iri}ยป, at grid cells {}",
1534 Self::format_hyperlink_match(hyperlink_match)
1535 )
1536 };
1537
1538 assert_eq!(
1539 self.expected_hyperlink.hyperlink_kind,
1540 HyperlinkKind::Iri,
1541 "\n at {}\nExpected a iri, but was a path:\n{}",
1542 self.source_location,
1543 self.format_renderable_content()
1544 );
1545
1546 assert_eq!(
1547 format_iri_and_match(
1548 &self.expected_hyperlink.iri_or_path,
1549 &self.expected_hyperlink.hyperlink_match
1550 ),
1551 format_iri_and_match(&iri, hyperlink_match),
1552 "\n at {}:\n{}",
1553 self.source_location,
1554 self.format_renderable_content()
1555 );
1556 }
1557
1558 fn format_hyperlink_match(hyperlink_match: &Match) -> String {
1559 format!(
1560 "({}, {})..=({}, {})",
1561 hyperlink_match.start().line.0,
1562 hyperlink_match.start().column.0,
1563 hyperlink_match.end().line.0,
1564 hyperlink_match.end().column.0
1565 )
1566 }
1567
1568 fn format_renderable_content(&self) -> String {
1569 let mut result = format!("\nHovered on '{}'\n", self.expected_hyperlink.hovered_char);
1570
1571 let mut first_header_row = String::new();
1572 let mut second_header_row = String::new();
1573 let mut marker_header_row = String::new();
1574 for index in 0..self.term.columns() {
1575 let remainder = index % 10;
1576 if index > 0 && remainder == 0 {
1577 first_header_row.push_str(&format!("{:>10}", (index / 10)));
1578 }
1579 second_header_row += &remainder.to_string();
1580 if index == self.expected_hyperlink.hovered_grid_point.column.0 {
1581 marker_header_row.push('โ');
1582 } else {
1583 marker_header_row.push(' ');
1584 }
1585 }
1586
1587 let remainder = (self.term.columns() - 1) % 10;
1588 if remainder != 0 {
1589 first_header_row.push_str(&" ".repeat(remainder));
1590 }
1591
1592 result += &format!("\n [ {}]\n", first_header_row);
1593 result += &format!(" [{}]\n", second_header_row);
1594 result += &format!(" {}", marker_header_row);
1595
1596 for cell in self
1597 .term
1598 .renderable_content()
1599 .display_iter
1600 .filter(|cell| !cell.flags.intersects(WIDE_CHAR_SPACERS))
1601 {
1602 if cell.point.column.0 == 0 {
1603 let prefix =
1604 if cell.point.line == self.expected_hyperlink.hovered_grid_point.line {
1605 'โ'
1606 } else {
1607 ' '
1608 };
1609 result += &format!("\n{prefix}[{:>3}] ", cell.point.line.to_string());
1610 }
1611
1612 match cell.c {
1613 '\t' => result.push(' '),
1614 c @ _ => result.push(c),
1615 }
1616 }
1617
1618 result
1619 }
1620 }
1621
1622 fn test_hyperlink<'a>(
1623 columns: usize,
1624 total_cells: usize,
1625 test_lines: impl Iterator<Item = &'a str>,
1626 hyperlink_kind: HyperlinkKind,
1627 source_location: &str,
1628 ) {
1629 const CARGO_DIR_REGEX: &str =
1630 r#"\s+(Compiling|Checking|Documenting) [^(]+\((?<link>(?<path>.+))\)"#;
1631 const RUST_DIAGNOSTIC_REGEX: &str = r#"\s+(-->|:::|at) (?<link>(?<path>.+?))(:$|$)"#;
1632 const ISSUE_12338_REGEX: &str =
1633 r#"[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2} (?<link>(?<path>.+))"#;
1634 const MULTIPLE_SAME_LINE_REGEX: &str =
1635 r#"(?<link>(?<path>๐ฆ multiple_same_line ๐ฆ) ๐ฃ(?<line>[0-9]+) ๐(?<column>[0-9]+)):"#;
1636 const PATH_HYPERLINK_TIMEOUT_MS: u64 = 1000;
1637
1638 thread_local! {
1639 static TEST_REGEX_SEARCHES: RefCell<RegexSearches> =
1640 RefCell::new({
1641 let default_settings_content: Rc<SettingsContent> =
1642 settings::parse_json_with_comments(&settings::default_settings()).unwrap();
1643 let default_terminal_settings = TerminalSettings::from_settings(&default_settings_content);
1644
1645 RegexSearches::new([
1646 RUST_DIAGNOSTIC_REGEX,
1647 CARGO_DIR_REGEX,
1648 ISSUE_12338_REGEX,
1649 MULTIPLE_SAME_LINE_REGEX,
1650 ]
1651 .into_iter()
1652 .chain(default_terminal_settings.path_hyperlink_regexes
1653 .iter()
1654 .map(AsRef::as_ref)),
1655 PATH_HYPERLINK_TIMEOUT_MS)
1656 });
1657 }
1658
1659 let term_size = TermSize::new(columns, total_cells / columns + 2);
1660 let (term, expected_hyperlink) =
1661 build_term_from_test_lines(hyperlink_kind, term_size, test_lines);
1662 let hyperlink_found = TEST_REGEX_SEARCHES.with(|regex_searches| {
1663 find_from_grid_point(
1664 &term,
1665 expected_hyperlink.hovered_grid_point,
1666 &mut regex_searches.borrow_mut(),
1667 )
1668 });
1669 let check_hyperlink_match =
1670 CheckHyperlinkMatch::new(&term, &expected_hyperlink, source_location);
1671 match hyperlink_found {
1672 Some((hyperlink_word, false, hyperlink_match)) => {
1673 check_hyperlink_match.check_path_with_position_and_match(
1674 PathWithPosition::parse_str(&hyperlink_word),
1675 &hyperlink_match,
1676 );
1677 }
1678 Some((hyperlink_word, true, hyperlink_match)) => {
1679 check_hyperlink_match.check_iri_and_match(hyperlink_word, &hyperlink_match);
1680 }
1681 None => {
1682 if expected_hyperlink.hyperlink_match.start()
1683 != expected_hyperlink.hyperlink_match.end()
1684 {
1685 assert!(
1686 false,
1687 "No hyperlink found\n at {source_location}:\n{}",
1688 check_hyperlink_match.format_renderable_content()
1689 )
1690 }
1691 }
1692 }
1693 }
1694}