1use alacritty_terminal::{
2 Term,
3 event::EventListener,
4 grid::Dimensions,
5 index::{Boundary, Column, Direction as AlacDirection, Line, Point as AlacPoint},
6 term::search::{Match, RegexIter, RegexSearch},
7};
8use regex::Regex;
9use std::{ops::Index, sync::LazyLock};
10
11const 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{-}\^⟨⟩`']+"#;
12// Optional suffix matches MSBuild diagnostic suffixes for path parsing in PathLikeWithPosition
13// https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-diagnostic-format-for-tasks
14const WORD_REGEX: &str =
15 r#"[\$\+\w.\[\]:/\\@\-~()]+(?:\((?:\d+|\d+,\d+)\))|[\$\+\w.\[\]:/\\@\-~()]+"#;
16
17const PYTHON_FILE_LINE_REGEX: &str = r#"File "(?P<file>[^"]+)", line (?P<line>\d+)"#;
18
19static PYTHON_FILE_LINE_MATCHER: LazyLock<Regex> =
20 LazyLock::new(|| Regex::new(PYTHON_FILE_LINE_REGEX).unwrap());
21
22fn python_extract_path_and_line(input: &str) -> Option<(&str, u32)> {
23 if let Some(captures) = PYTHON_FILE_LINE_MATCHER.captures(input) {
24 let path_part = captures.name("file")?.as_str();
25
26 let line_number: u32 = captures.name("line")?.as_str().parse().ok()?;
27 return Some((path_part, line_number));
28 }
29 None
30}
31
32pub(super) struct RegexSearches {
33 url_regex: RegexSearch,
34 word_regex: RegexSearch,
35 python_file_line_regex: RegexSearch,
36}
37
38impl RegexSearches {
39 pub(super) fn new() -> Self {
40 Self {
41 url_regex: RegexSearch::new(URL_REGEX).unwrap(),
42 word_regex: RegexSearch::new(WORD_REGEX).unwrap(),
43 python_file_line_regex: RegexSearch::new(PYTHON_FILE_LINE_REGEX).unwrap(),
44 }
45 }
46}
47
48pub(super) fn find_from_grid_point<T: EventListener>(
49 term: &Term<T>,
50 point: AlacPoint,
51 regex_searches: &mut RegexSearches,
52) -> Option<(String, bool, Match)> {
53 let grid = term.grid();
54 let link = grid.index(point).hyperlink();
55 let found_word = if let Some(ref url) = link {
56 let mut min_index = point;
57 loop {
58 let new_min_index = min_index.sub(term, Boundary::Cursor, 1);
59 if new_min_index == min_index || grid.index(new_min_index).hyperlink() != link {
60 break;
61 } else {
62 min_index = new_min_index
63 }
64 }
65
66 let mut max_index = point;
67 loop {
68 let new_max_index = max_index.add(term, Boundary::Cursor, 1);
69 if new_max_index == max_index || grid.index(new_max_index).hyperlink() != link {
70 break;
71 } else {
72 max_index = new_max_index
73 }
74 }
75
76 let url = url.uri().to_owned();
77 let url_match = min_index..=max_index;
78
79 Some((url, true, url_match))
80 } else if let Some(url_match) = regex_match_at(term, point, &mut regex_searches.url_regex) {
81 let url = term.bounds_to_string(*url_match.start(), *url_match.end());
82 let (sanitized_url, sanitized_match) = sanitize_url_punctuation(url, url_match, term);
83 Some((sanitized_url, true, sanitized_match))
84 } else if let Some(python_match) =
85 regex_match_at(term, point, &mut regex_searches.python_file_line_regex)
86 {
87 let matching_line = term.bounds_to_string(*python_match.start(), *python_match.end());
88 python_extract_path_and_line(&matching_line).map(|(file_path, line_number)| {
89 (format!("{file_path}:{line_number}"), false, python_match)
90 })
91 } else if let Some(word_match) = regex_match_at(term, point, &mut regex_searches.word_regex) {
92 let file_path = term.bounds_to_string(*word_match.start(), *word_match.end());
93
94 let (sanitized_match, sanitized_word) = 'sanitize: {
95 let mut word_match = word_match;
96 let mut file_path = file_path;
97
98 if is_path_surrounded_by_common_symbols(&file_path) {
99 word_match = Match::new(
100 word_match.start().add(term, Boundary::Grid, 1),
101 word_match.end().sub(term, Boundary::Grid, 1),
102 );
103 file_path = file_path[1..file_path.len() - 1].to_owned();
104 }
105
106 while file_path.ends_with(':') {
107 file_path.pop();
108 word_match = Match::new(
109 *word_match.start(),
110 word_match.end().sub(term, Boundary::Grid, 1),
111 );
112 }
113 let mut colon_count = 0;
114 for c in file_path.chars() {
115 if c == ':' {
116 colon_count += 1;
117 }
118 }
119 // strip trailing comment after colon in case of
120 // file/at/path.rs:row:column:description or error message
121 // so that the file path is `file/at/path.rs:row:column`
122 if colon_count > 2 {
123 let last_index = file_path.rfind(':').unwrap();
124 let prev_is_digit = last_index > 0
125 && file_path
126 .chars()
127 .nth(last_index - 1)
128 .is_some_and(|c| c.is_ascii_digit());
129 let next_is_digit = last_index < file_path.len() - 1
130 && file_path
131 .chars()
132 .nth(last_index + 1)
133 .is_none_or(|c| c.is_ascii_digit());
134 if prev_is_digit && !next_is_digit {
135 let stripped_len = file_path.len() - last_index;
136 word_match = Match::new(
137 *word_match.start(),
138 word_match.end().sub(term, Boundary::Grid, stripped_len),
139 );
140 file_path = file_path[0..last_index].to_owned();
141 }
142 }
143
144 break 'sanitize (word_match, file_path);
145 };
146
147 Some((sanitized_word, false, sanitized_match))
148 } else {
149 None
150 };
151
152 found_word.map(|(maybe_url_or_path, is_url, word_match)| {
153 if is_url {
154 // Treat "file://" IRIs like file paths to ensure
155 // that line numbers at the end of the path are
156 // handled correctly
157 if let Some(path) = maybe_url_or_path.strip_prefix("file://") {
158 (path.to_string(), false, word_match)
159 } else {
160 (maybe_url_or_path, true, word_match)
161 }
162 } else {
163 (maybe_url_or_path, false, word_match)
164 }
165 })
166}
167
168fn sanitize_url_punctuation<T: EventListener>(
169 url: String,
170 url_match: Match,
171 term: &Term<T>,
172) -> (String, Match) {
173 let mut sanitized_url = url;
174 let mut chars_trimmed = 0;
175
176 // First, handle parentheses balancing using single traversal
177 let (open_parens, close_parens) =
178 sanitized_url
179 .chars()
180 .fold((0, 0), |(opens, closes), c| match c {
181 '(' => (opens + 1, closes),
182 ')' => (opens, closes + 1),
183 _ => (opens, closes),
184 });
185
186 // Trim unbalanced closing parentheses
187 if close_parens > open_parens {
188 let mut remaining_close = close_parens;
189 while sanitized_url.ends_with(')') && remaining_close > open_parens {
190 sanitized_url.pop();
191 chars_trimmed += 1;
192 remaining_close -= 1;
193 }
194 }
195
196 // Handle trailing periods
197 if sanitized_url.ends_with('.') {
198 let trailing_periods = sanitized_url
199 .chars()
200 .rev()
201 .take_while(|&c| c == '.')
202 .count();
203
204 if trailing_periods > 1 {
205 sanitized_url.truncate(sanitized_url.len() - trailing_periods);
206 chars_trimmed += trailing_periods;
207 } else if trailing_periods == 1
208 && let Some(second_last_char) = sanitized_url.chars().rev().nth(1)
209 && (second_last_char.is_alphanumeric() || second_last_char == '/')
210 {
211 sanitized_url.pop();
212 chars_trimmed += 1;
213 }
214 }
215
216 if chars_trimmed > 0 {
217 let new_end = url_match.end().sub(term, Boundary::Grid, chars_trimmed);
218 let sanitized_match = Match::new(*url_match.start(), new_end);
219 (sanitized_url, sanitized_match)
220 } else {
221 (sanitized_url, url_match)
222 }
223}
224
225fn is_path_surrounded_by_common_symbols(path: &str) -> bool {
226 // Avoid detecting `[]` or `()` strings as paths, surrounded by common symbols
227 path.len() > 2
228 // The rest of the brackets and various quotes cannot be matched by the [`WORD_REGEX`] hence not checked for.
229 && (path.starts_with('[') && path.ends_with(']')
230 || path.starts_with('(') && path.ends_with(')'))
231}
232
233/// Based on alacritty/src/display/hint.rs > regex_match_at
234/// Retrieve the match, if the specified point is inside the content matching the regex.
235fn regex_match_at<T>(term: &Term<T>, point: AlacPoint, regex: &mut RegexSearch) -> Option<Match> {
236 visible_regex_match_iter(term, regex).find(|rm| rm.contains(&point))
237}
238
239/// Copied from alacritty/src/display/hint.rs:
240/// Iterate over all visible regex matches.
241fn visible_regex_match_iter<'a, T>(
242 term: &'a Term<T>,
243 regex: &'a mut RegexSearch,
244) -> impl Iterator<Item = Match> + 'a {
245 const MAX_SEARCH_LINES: usize = 100;
246
247 let viewport_start = Line(-(term.grid().display_offset() as i32));
248 let viewport_end = viewport_start + term.bottommost_line();
249 let mut start = term.line_search_left(AlacPoint::new(viewport_start, Column(0)));
250 let mut end = term.line_search_right(AlacPoint::new(viewport_end, Column(0)));
251 start.line = start.line.max(viewport_start - MAX_SEARCH_LINES);
252 end.line = end.line.min(viewport_end + MAX_SEARCH_LINES);
253
254 RegexIter::new(start, end, AlacDirection::Right, term, regex)
255 .skip_while(move |rm| rm.end().line < viewport_start)
256 .take_while(move |rm| rm.start().line <= viewport_end)
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use alacritty_terminal::{
263 event::VoidListener,
264 index::{Boundary, Column, Line, Point as AlacPoint},
265 term::{Config, cell::Flags, search::Match, test::TermSize},
266 vte::ansi::Handler,
267 };
268 use std::{cell::RefCell, ops::RangeInclusive, path::PathBuf};
269 use url::Url;
270 use util::paths::PathWithPosition;
271
272 fn re_test(re: &str, hay: &str, expected: Vec<&str>) {
273 let results: Vec<_> = regex::Regex::new(re)
274 .unwrap()
275 .find_iter(hay)
276 .map(|m| m.as_str())
277 .collect();
278 assert_eq!(results, expected);
279 }
280
281 #[test]
282 fn test_url_regex() {
283 re_test(
284 URL_REGEX,
285 "test http://example.com test 'https://website1.com' test mailto:bob@example.com train",
286 vec![
287 "http://example.com",
288 "https://website1.com",
289 "mailto:bob@example.com",
290 ],
291 );
292 }
293
294 #[test]
295 fn test_url_parentheses_sanitization() {
296 // Test our sanitize_url_parentheses function directly
297 let test_cases = vec![
298 // Cases that should be sanitized (unbalanced parentheses)
299 ("https://www.google.com/)", "https://www.google.com/"),
300 ("https://example.com/path)", "https://example.com/path"),
301 ("https://test.com/))", "https://test.com/"),
302 // Cases that should NOT be sanitized (balanced parentheses)
303 (
304 "https://en.wikipedia.org/wiki/Example_(disambiguation)",
305 "https://en.wikipedia.org/wiki/Example_(disambiguation)",
306 ),
307 ("https://test.com/(hello)", "https://test.com/(hello)"),
308 (
309 "https://example.com/path(1)(2)",
310 "https://example.com/path(1)(2)",
311 ),
312 // Edge cases
313 ("https://test.com/", "https://test.com/"),
314 ("https://example.com", "https://example.com"),
315 ];
316
317 for (input, expected) in test_cases {
318 // Create a minimal terminal for testing
319 let term = Term::new(Config::default(), &TermSize::new(80, 24), VoidListener);
320
321 // Create a dummy match that spans the entire input
322 let start_point = AlacPoint::new(Line(0), Column(0));
323 let end_point = AlacPoint::new(Line(0), Column(input.len()));
324 let dummy_match = Match::new(start_point, end_point);
325
326 let (result, _) = sanitize_url_punctuation(input.to_string(), dummy_match, &term);
327 assert_eq!(result, expected, "Failed for input: {}", input);
328 }
329 }
330
331 #[test]
332 fn test_url_periods_sanitization() {
333 // Test URLs with trailing periods (sentence punctuation)
334 let test_cases = vec![
335 // Cases that should be sanitized (trailing periods likely punctuation)
336 ("https://example.com.", "https://example.com"),
337 (
338 "https://github.com/zed-industries/zed.",
339 "https://github.com/zed-industries/zed",
340 ),
341 (
342 "https://example.com/path/file.html.",
343 "https://example.com/path/file.html",
344 ),
345 (
346 "https://example.com/file.pdf.",
347 "https://example.com/file.pdf",
348 ),
349 ("https://example.com:8080.", "https://example.com:8080"),
350 ("https://example.com..", "https://example.com"),
351 (
352 "https://en.wikipedia.org/wiki/C.E.O.",
353 "https://en.wikipedia.org/wiki/C.E.O",
354 ),
355 // Cases that should NOT be sanitized (periods are part of URL structure)
356 (
357 "https://example.com/v1.0/api",
358 "https://example.com/v1.0/api",
359 ),
360 ("https://192.168.1.1", "https://192.168.1.1"),
361 ("https://sub.domain.com", "https://sub.domain.com"),
362 ];
363
364 for (input, expected) in test_cases {
365 // Create a minimal terminal for testing
366 let term = Term::new(Config::default(), &TermSize::new(80, 24), VoidListener);
367
368 // Create a dummy match that spans the entire input
369 let start_point = AlacPoint::new(Line(0), Column(0));
370 let end_point = AlacPoint::new(Line(0), Column(input.len()));
371 let dummy_match = Match::new(start_point, end_point);
372
373 // This test should initially fail since we haven't implemented period sanitization yet
374 let (result, _) = sanitize_url_punctuation(input.to_string(), dummy_match, &term);
375 assert_eq!(result, expected, "Failed for input: {}", input);
376 }
377 }
378
379 #[test]
380 fn test_word_regex() {
381 re_test(
382 WORD_REGEX,
383 "hello, world! \"What\" is this?",
384 vec!["hello", "world", "What", "is", "this"],
385 );
386 }
387
388 #[test]
389 fn test_word_regex_with_linenum() {
390 // filename(line) and filename(line,col) as used in MSBuild output
391 // should be considered a single "word", even though comma is
392 // usually a word separator
393 re_test(WORD_REGEX, "a Main.cs(20) b", vec!["a", "Main.cs(20)", "b"]);
394 re_test(
395 WORD_REGEX,
396 "Main.cs(20,5) Error desc",
397 vec!["Main.cs(20,5)", "Error", "desc"],
398 );
399 // filename:line:col is a popular format for unix tools
400 re_test(
401 WORD_REGEX,
402 "a Main.cs:20:5 b",
403 vec!["a", "Main.cs:20:5", "b"],
404 );
405 // Some tools output "filename:line:col:message", which currently isn't
406 // handled correctly, but might be in the future
407 re_test(
408 WORD_REGEX,
409 "Main.cs:20:5:Error desc",
410 vec!["Main.cs:20:5:Error", "desc"],
411 );
412 }
413
414 #[test]
415 fn test_python_file_line_regex() {
416 re_test(
417 PYTHON_FILE_LINE_REGEX,
418 "hay File \"/zed/bad_py.py\", line 8 stack",
419 vec!["File \"/zed/bad_py.py\", line 8"],
420 );
421 re_test(PYTHON_FILE_LINE_REGEX, "unrelated", vec![]);
422 }
423
424 #[test]
425 fn test_python_file_line() {
426 let inputs: Vec<(&str, Option<(&str, u32)>)> = vec![
427 (
428 "File \"/zed/bad_py.py\", line 8",
429 Some(("/zed/bad_py.py", 8u32)),
430 ),
431 ("File \"path/to/zed/bad_py.py\"", None),
432 ("unrelated", None),
433 ("", None),
434 ];
435 let actual = inputs
436 .iter()
437 .map(|input| python_extract_path_and_line(input.0))
438 .collect::<Vec<_>>();
439 let expected = inputs.iter().map(|(_, output)| *output).collect::<Vec<_>>();
440 assert_eq!(actual, expected);
441 }
442
443 // We use custom columns in many tests to workaround this issue by ensuring a wrapped
444 // line never ends on a wide char:
445 //
446 // <https://github.com/alacritty/alacritty/issues/8586>
447 //
448 // This issue was recently fixed, as soon as we update to a version containing the fix we
449 // can remove all the custom columns from these tests.
450 //
451 macro_rules! test_hyperlink {
452 ($($lines:expr),+; $hyperlink_kind:ident) => { {
453 use crate::terminal_hyperlinks::tests::line_cells_count;
454 use std::cmp;
455
456 let test_lines = vec![$($lines),+];
457 let (total_cells, longest_line_cells) =
458 test_lines.iter().copied()
459 .map(line_cells_count)
460 .fold((0, 0), |state, cells| (state.0 + cells, cmp::max(state.1, cells)));
461
462 test_hyperlink!(
463 // Alacritty has issues with 2 columns, use 3 as the minimum for now.
464 [3, longest_line_cells / 2, longest_line_cells + 1];
465 total_cells;
466 test_lines.iter().copied();
467 $hyperlink_kind
468 )
469 } };
470
471 ($($columns:literal),+; $($lines:expr),+; $hyperlink_kind:ident) => { {
472 use crate::terminal_hyperlinks::tests::line_cells_count;
473
474 let test_lines = vec![$($lines),+];
475 let total_cells = test_lines.iter().copied().map(line_cells_count).sum();
476
477 test_hyperlink!(
478 [ $($columns),+ ]; total_cells; test_lines.iter().copied(); $hyperlink_kind
479 )
480 } };
481
482 ([ $($columns:expr),+ ]; $total_cells:expr; $lines:expr; $hyperlink_kind:ident) => { {
483 use crate::terminal_hyperlinks::tests::{ test_hyperlink, HyperlinkKind };
484
485 let source_location = format!("{}:{}", std::file!(), std::line!());
486 for columns in vec![ $($columns),+] {
487 test_hyperlink(columns, $total_cells, $lines, HyperlinkKind::$hyperlink_kind,
488 &source_location);
489 }
490 } };
491 }
492
493 mod path {
494 /// 👉 := **hovered** on following char
495 ///
496 /// 👈 := **hovered** on wide char spacer of previous full width char
497 ///
498 /// **`‹›`** := expected **hyperlink** match
499 ///
500 /// **`«»`** := expected **path**, **row**, and **column** capture groups
501 ///
502 /// [**`c₀, c₁, …, cₙ;`**]ₒₚₜ := use specified terminal widths of `c₀, c₁, …, cₙ` **columns**
503 /// (defaults to `3, longest_line_cells / 2, longest_line_cells + 1;`)
504 ///
505 macro_rules! test_path {
506 ($($lines:literal),+) => { test_hyperlink!($($lines),+; Path) };
507 ($($columns:literal),+; $($lines:literal),+) => {
508 test_hyperlink!($($columns),+; $($lines),+; Path)
509 };
510 }
511
512 #[test]
513 fn simple() {
514 // Rust paths
515 // Just the path
516 test_path!("‹«/👉test/cool.rs»›");
517 test_path!("‹«/test/cool👉.rs»›");
518
519 // path and line
520 test_path!("‹«/👉test/cool.rs»:«4»›");
521 test_path!("‹«/test/cool.rs»👉:«4»›");
522 test_path!("‹«/test/cool.rs»:«👉4»›");
523 test_path!("‹«/👉test/cool.rs»(«4»)›");
524 test_path!("‹«/test/cool.rs»👉(«4»)›");
525 test_path!("‹«/test/cool.rs»(«👉4»)›");
526 test_path!("‹«/test/cool.rs»(«4»👉)›");
527
528 // path, line, and column
529 test_path!("‹«/👉test/cool.rs»:«4»:«2»›");
530 test_path!("‹«/test/cool.rs»:«4»:«👉2»›");
531 test_path!("‹«/👉test/cool.rs»(«4»,«2»)›");
532 test_path!("‹«/test/cool.rs»(«4»👉,«2»)›");
533
534 // path, line, column, and ':' suffix
535 test_path!("‹«/👉test/cool.rs»:«4»:«2»›:");
536 test_path!("‹«/test/cool.rs»:«4»:«👉2»›:");
537 test_path!("‹«/👉test/cool.rs»(«4»,«2»)›:");
538 test_path!("‹«/test/cool.rs»(«4»,«2»👉)›:");
539
540 // path, line, column, and description
541 test_path!("‹«/test/cool.rs»:«4»:«2»›👉:Error!");
542 test_path!("‹«/test/cool.rs»:«4»:«2»›:👉Error!");
543 test_path!("‹«/test/co👉ol.rs»(«4»,«2»)›:Error!");
544
545 // Cargo output
546 test_path!(" Compiling Cool 👉(‹«/test/Cool»›)");
547 test_path!(" Compiling Cool (‹«/👉test/Cool»›)");
548 test_path!(" Compiling Cool (‹«/test/Cool»›👉)");
549
550 // Python
551 test_path!("‹«awe👉some.py»›");
552
553 test_path!(" ‹F👉ile \"«/awesome.py»\", line «42»›: Wat?");
554 test_path!(" ‹File \"«/awe👉some.py»\", line «42»›: Wat?");
555 test_path!(" ‹File \"«/awesome.py»👉\", line «42»›: Wat?");
556 test_path!(" ‹File \"«/awesome.py»\", line «4👉2»›: Wat?");
557 }
558
559 #[test]
560 fn colons_galore() {
561 test_path!("‹«/test/co👉ol.rs»:«4»›");
562 test_path!("‹«/test/co👉ol.rs»:«4»›:");
563 test_path!("‹«/test/co👉ol.rs»:«4»:«2»›");
564 test_path!("‹«/test/co👉ol.rs»:«4»:«2»›:");
565 test_path!("‹«/test/co👉ol.rs»(«1»)›");
566 test_path!("‹«/test/co👉ol.rs»(«1»)›:");
567 test_path!("‹«/test/co👉ol.rs»(«1»,«618»)›");
568 test_path!("‹«/test/co👉ol.rs»(«1»,«618»)›:");
569 test_path!("‹«/test/co👉ol.rs»::«42»›");
570 test_path!("‹«/test/co👉ol.rs»::«42»›:");
571 test_path!("‹«/test/co👉ol.rs:4:2»(«1»,«618»)›");
572 test_path!("‹«/test/co👉ol.rs»(«1»,«618»)›::");
573 }
574
575 #[test]
576 fn word_wide_chars() {
577 // Rust paths
578 test_path!(4, 6, 12; "‹«/👉例/cool.rs»›");
579 test_path!(4, 6, 12; "‹«/例👈/cool.rs»›");
580 test_path!(4, 8, 16; "‹«/例/cool.rs»:«👉4»›");
581 test_path!(4, 8, 16; "‹«/例/cool.rs»:«4»:«👉2»›");
582
583 // Cargo output
584 test_path!(4, 27, 30; " Compiling Cool (‹«/👉例/Cool»›)");
585 test_path!(4, 27, 30; " Compiling Cool (‹«/例👈/Cool»›)");
586
587 // Python
588 test_path!(4, 11; "‹«👉例wesome.py»›");
589 test_path!(4, 11; "‹«例👈wesome.py»›");
590 test_path!(6, 17, 40; " ‹File \"«/👉例wesome.py»\", line «42»›: Wat?");
591 test_path!(6, 17, 40; " ‹File \"«/例👈wesome.py»\", line «42»›: Wat?");
592 }
593
594 #[test]
595 fn non_word_wide_chars() {
596 // Mojo diagnostic message
597 test_path!(4, 18, 38; " ‹File \"«/awe👉some.🔥»\", line «42»›: Wat?");
598 test_path!(4, 18, 38; " ‹File \"«/awesome👉.🔥»\", line «42»›: Wat?");
599 test_path!(4, 18, 38; " ‹File \"«/awesome.👉🔥»\", line «42»›: Wat?");
600 test_path!(4, 18, 38; " ‹File \"«/awesome.🔥👈»\", line «42»›: Wat?");
601 }
602
603 /// These likely rise to the level of being worth fixing.
604 mod issues {
605 #[test]
606 #[cfg_attr(not(target_os = "windows"), should_panic(expected = "Path = «例»"))]
607 #[cfg_attr(target_os = "windows", should_panic(expected = r#"Path = «C:\\例»"#))]
608 // <https://github.com/alacritty/alacritty/issues/8586>
609 fn issue_alacritty_8586() {
610 // Rust paths
611 test_path!("‹«/👉例/cool.rs»›");
612 test_path!("‹«/例👈/cool.rs»›");
613 test_path!("‹«/例/cool.rs»:«👉4»›");
614 test_path!("‹«/例/cool.rs»:«4»:«👉2»›");
615
616 // Cargo output
617 test_path!(" Compiling Cool (‹«/👉例/Cool»›)");
618 test_path!(" Compiling Cool (‹«/例👈/Cool»›)");
619
620 // Python
621 test_path!("‹«👉例wesome.py»›");
622 test_path!("‹«例👈wesome.py»›");
623 test_path!(" ‹File \"«/👉例wesome.py»\", line «42»›: Wat?");
624 test_path!(" ‹File \"«/例👈wesome.py»\", line «42»›: Wat?");
625 }
626
627 #[test]
628 #[should_panic(expected = "No hyperlink found")]
629 // <https://github.com/zed-industries/zed/issues/12338>
630 fn issue_12338() {
631 // Issue #12338
632 test_path!(".rw-r--r-- 0 staff 05-27 14:03 ‹«test👉、2.txt»›");
633 test_path!(".rw-r--r-- 0 staff 05-27 14:03 ‹«test、👈2.txt»›");
634 test_path!(".rw-r--r-- 0 staff 05-27 14:03 ‹«test👉。3.txt»›");
635 test_path!(".rw-r--r-- 0 staff 05-27 14:03 ‹«test。👈3.txt»›");
636
637 // Rust paths
638 test_path!("‹«/👉🏃/🦀.rs»›");
639 test_path!("‹«/🏃👈/🦀.rs»›");
640 test_path!("‹«/🏃/👉🦀.rs»:«4»›");
641 test_path!("‹«/🏃/🦀👈.rs»:«4»:«2»›");
642
643 // Cargo output
644 test_path!(" Compiling Cool (‹«/👉🏃/Cool»›)");
645 test_path!(" Compiling Cool (‹«/🏃👈/Cool»›)");
646
647 // Python
648 test_path!("‹«👉🏃wesome.py»›");
649 test_path!("‹«🏃👈wesome.py»›");
650 test_path!(" ‹File \"«/👉🏃wesome.py»\", line «42»›: Wat?");
651 test_path!(" ‹File \"«/🏃👈wesome.py»\", line «42»›: Wat?");
652
653 // Mojo
654 test_path!("‹«/awe👉some.🔥»› is some good Mojo!");
655 test_path!("‹«/awesome👉.🔥»› is some good Mojo!");
656 test_path!("‹«/awesome.👉🔥»› is some good Mojo!");
657 test_path!("‹«/awesome.🔥👈»› is some good Mojo!");
658 test_path!(" ‹File \"«/👉🏃wesome.🔥»\", line «42»›: Wat?");
659 test_path!(" ‹File \"«/🏃👈wesome.🔥»\", line «42»›: Wat?");
660 }
661
662 #[test]
663 #[cfg_attr(
664 not(target_os = "windows"),
665 should_panic(
666 expected = "Path = «test/controllers/template_items_controller_test.rb», line = 20, at grid cells (0, 0)..=(17, 1)"
667 )
668 )]
669 #[cfg_attr(
670 target_os = "windows",
671 should_panic(
672 expected = r#"Path = «test\\controllers\\template_items_controller_test.rb», line = 20, at grid cells (0, 0)..=(17, 1)"#
673 )
674 )]
675 // <https://github.com/zed-industries/zed/issues/28194>
676 //
677 // #28194 was closed, but the link includes the description part (":in" here), which
678 // seems wrong...
679 fn issue_28194() {
680 test_path!(
681 "‹«test/c👉ontrollers/template_items_controller_test.rb»:«20»›:in 'block (2 levels) in <class:TemplateItemsControllerTest>'"
682 );
683 test_path!(
684 "‹«test/controllers/template_items_controller_test.rb»:«19»›:i👉n 'block in <class:TemplateItemsControllerTest>'"
685 );
686 }
687 }
688
689 /// Minor issues arguably not important enough to fix/workaround...
690 mod nits {
691 #[test]
692 #[cfg_attr(
693 not(target_os = "windows"),
694 should_panic(expected = "Path = «/test/cool.rs(4»")
695 )]
696 #[cfg_attr(
697 target_os = "windows",
698 should_panic(expected = r#"Path = «C:\\test\\cool.rs(4»"#)
699 )]
700 fn alacritty_bugs_with_two_columns() {
701 test_path!(2; "‹«/👉test/cool.rs»(«4»)›");
702 test_path!(2; "‹«/test/cool.rs»(«👉4»)›");
703 test_path!(2; "‹«/test/cool.rs»(«4»,«👉2»)›");
704
705 // Python
706 test_path!(2; "‹«awe👉some.py»›");
707 }
708
709 #[test]
710 #[cfg_attr(
711 not(target_os = "windows"),
712 should_panic(
713 expected = "Path = «/test/cool.rs», line = 1, at grid cells (0, 0)..=(9, 0)"
714 )
715 )]
716 #[cfg_attr(
717 target_os = "windows",
718 should_panic(
719 expected = r#"Path = «C:\\test\\cool.rs», line = 1, at grid cells (0, 0)..=(9, 2)"#
720 )
721 )]
722 fn invalid_row_column_should_be_part_of_path() {
723 test_path!("‹«/👉test/cool.rs:1:618033988749»›");
724 test_path!("‹«/👉test/cool.rs(1,618033988749)»›");
725 }
726
727 #[test]
728 #[should_panic(expected = "Path = «»")]
729 fn colon_suffix_succeeds_in_finding_an_empty_maybe_path() {
730 test_path!("‹«/test/cool.rs»:«4»:«2»›👉:", "What is this?");
731 test_path!("‹«/test/cool.rs»(«4»,«2»)›👉:", "What is this?");
732 }
733
734 #[test]
735 #[cfg_attr(
736 not(target_os = "windows"),
737 should_panic(expected = "Path = «/test/cool.rs»")
738 )]
739 #[cfg_attr(
740 target_os = "windows",
741 should_panic(expected = r#"Path = «C:\\test\\cool.rs»"#)
742 )]
743 fn many_trailing_colons_should_be_parsed_as_part_of_the_path() {
744 test_path!("‹«/test/cool.rs:::👉:»›");
745 test_path!("‹«/te:st/👉co:ol.r:s:4:2::::::»›");
746 }
747 }
748
749 #[cfg(target_os = "windows")]
750 mod windows {
751 // Lots of fun to be had with long file paths (verbatim) and UNC paths on Windows.
752 // See <https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation>
753 // See <https://users.rust-lang.org/t/understanding-windows-paths/58583>
754 // See <https://github.com/rust-lang/cargo/issues/13919>
755
756 #[test]
757 fn unc() {
758 test_path!(r#"‹«\\server\share\👉test\cool.rs»›"#);
759 test_path!(r#"‹«\\server\share\test\cool👉.rs»›"#);
760 }
761
762 mod issues {
763 #[test]
764 #[should_panic(
765 expected = r#"Path = «C:\\test\\cool.rs», at grid cells (0, 0)..=(6, 0)"#
766 )]
767 fn issue_verbatim() {
768 test_path!(r#"‹«\\?\C:\👉test\cool.rs»›"#);
769 test_path!(r#"‹«\\?\C:\test\cool👉.rs»›"#);
770 }
771
772 #[test]
773 #[should_panic(
774 expected = r#"Path = «\\\\server\\share\\test\\cool.rs», at grid cells (0, 0)..=(10, 2)"#
775 )]
776 fn issue_verbatim_unc() {
777 test_path!(r#"‹«\\?\UNC\server\share\👉test\cool.rs»›"#);
778 test_path!(r#"‹«\\?\UNC\server\share\test\cool👉.rs»›"#);
779 }
780 }
781 }
782 }
783
784 mod file_iri {
785 // File IRIs have a ton of use cases, most of which we currently do not support. A few of
786 // those cases are documented here as tests which are expected to fail.
787 // See https://en.wikipedia.org/wiki/File_URI_scheme
788
789 /// [**`c₀, c₁, …, cₙ;`**]ₒₚₜ := use specified terminal widths of `c₀, c₁, …, cₙ` **columns**
790 /// (defaults to `3, longest_line_cells / 2, longest_line_cells + 1;`)
791 ///
792 macro_rules! test_file_iri {
793 ($file_iri:literal) => { { test_hyperlink!(concat!("‹«👉", $file_iri, "»›"); FileIri) } };
794 ($($columns:literal),+; $file_iri:literal) => { {
795 test_hyperlink!($($columns),+; concat!("‹«👉", $file_iri, "»›"); FileIri)
796 } };
797 }
798
799 #[cfg(not(target_os = "windows"))]
800 #[test]
801 fn absolute_file_iri() {
802 test_file_iri!("file:///test/cool/index.rs");
803 test_file_iri!("file:///test/cool/");
804 }
805
806 mod issues {
807 #[cfg(not(target_os = "windows"))]
808 #[test]
809 #[should_panic(expected = "Path = «/test/Ῥόδος/», at grid cells (0, 0)..=(15, 1)")]
810 fn issue_file_iri_with_percent_encoded_characters() {
811 // Non-space characters
812 // file:///test/Ῥόδος/
813 test_file_iri!("file:///test/%E1%BF%AC%CF%8C%CE%B4%CE%BF%CF%82/"); // URI
814
815 // Spaces
816 test_file_iri!("file:///te%20st/co%20ol/index.rs");
817 test_file_iri!("file:///te%20st/co%20ol/");
818 }
819 }
820
821 #[cfg(target_os = "windows")]
822 mod windows {
823 mod issues {
824 // The test uses Url::to_file_path(), but it seems that the Url crate doesn't
825 // support relative file IRIs.
826 #[test]
827 #[should_panic(
828 expected = r#"Failed to interpret file IRI `file:/test/cool/index.rs` as a path"#
829 )]
830 fn issue_relative_file_iri() {
831 test_file_iri!("file:/test/cool/index.rs");
832 test_file_iri!("file:/test/cool/");
833 }
834
835 // See https://en.wikipedia.org/wiki/File_URI_scheme
836 #[test]
837 #[should_panic(
838 expected = r#"Path = «C:\\test\\cool\\index.rs», at grid cells (0, 0)..=(9, 1)"#
839 )]
840 fn issue_absolute_file_iri() {
841 test_file_iri!("file:///C:/test/cool/index.rs");
842 test_file_iri!("file:///C:/test/cool/");
843 }
844
845 #[test]
846 #[should_panic(
847 expected = r#"Path = «C:\\test\\Ῥόδος\\», at grid cells (0, 0)..=(16, 1)"#
848 )]
849 fn issue_file_iri_with_percent_encoded_characters() {
850 // Non-space characters
851 // file:///test/Ῥόδος/
852 test_file_iri!("file:///C:/test/%E1%BF%AC%CF%8C%CE%B4%CE%BF%CF%82/"); // URI
853
854 // Spaces
855 test_file_iri!("file:///C:/te%20st/co%20ol/index.rs");
856 test_file_iri!("file:///C:/te%20st/co%20ol/");
857 }
858 }
859 }
860 }
861
862 mod iri {
863 /// [**`c₀, c₁, …, cₙ;`**]ₒₚₜ := use specified terminal widths of `c₀, c₁, …, cₙ` **columns**
864 /// (defaults to `3, longest_line_cells / 2, longest_line_cells + 1;`)
865 ///
866 macro_rules! test_iri {
867 ($iri:literal) => { { test_hyperlink!(concat!("‹«👉", $iri, "»›"); Iri) } };
868 ($($columns:literal),+; $iri:literal) => { {
869 test_hyperlink!($($columns),+; concat!("‹«👉", $iri, "»›"); Iri)
870 } };
871 }
872
873 #[test]
874 fn simple() {
875 // In the order they appear in URL_REGEX, except 'file://' which is treated as a path
876 test_iri!("ipfs://test/cool.ipfs");
877 test_iri!("ipns://test/cool.ipns");
878 test_iri!("magnet://test/cool.git");
879 test_iri!("mailto:someone@somewhere.here");
880 test_iri!("gemini://somewhere.here");
881 test_iri!("gopher://somewhere.here");
882 test_iri!("http://test/cool/index.html");
883 test_iri!("http://10.10.10.10:1111/cool.html");
884 test_iri!("http://test/cool/index.html?amazing=1");
885 test_iri!("http://test/cool/index.html#right%20here");
886 test_iri!("http://test/cool/index.html?amazing=1#right%20here");
887 test_iri!("https://test/cool/index.html");
888 test_iri!("https://10.10.10.10:1111/cool.html");
889 test_iri!("https://test/cool/index.html?amazing=1");
890 test_iri!("https://test/cool/index.html#right%20here");
891 test_iri!("https://test/cool/index.html?amazing=1#right%20here");
892 test_iri!("news://test/cool.news");
893 test_iri!("git://test/cool.git");
894 test_iri!("ssh://user@somewhere.over.here:12345/test/cool.git");
895 test_iri!("ftp://test/cool.ftp");
896 }
897
898 #[test]
899 fn wide_chars() {
900 // In the order they appear in URL_REGEX, except 'file://' which is treated as a path
901 test_iri!(4, 20; "ipfs://例🏃🦀/cool.ipfs");
902 test_iri!(4, 20; "ipns://例🏃🦀/cool.ipns");
903 test_iri!(6, 20; "magnet://例🏃🦀/cool.git");
904 test_iri!(4, 20; "mailto:someone@somewhere.here");
905 test_iri!(4, 20; "gemini://somewhere.here");
906 test_iri!(4, 20; "gopher://somewhere.here");
907 test_iri!(4, 20; "http://例🏃🦀/cool/index.html");
908 test_iri!(4, 20; "http://10.10.10.10:1111/cool.html");
909 test_iri!(4, 20; "http://例🏃🦀/cool/index.html?amazing=1");
910 test_iri!(4, 20; "http://例🏃🦀/cool/index.html#right%20here");
911 test_iri!(4, 20; "http://例🏃🦀/cool/index.html?amazing=1#right%20here");
912 test_iri!(4, 20; "https://例🏃🦀/cool/index.html");
913 test_iri!(4, 20; "https://10.10.10.10:1111/cool.html");
914 test_iri!(4, 20; "https://例🏃🦀/cool/index.html?amazing=1");
915 test_iri!(4, 20; "https://例🏃🦀/cool/index.html#right%20here");
916 test_iri!(4, 20; "https://例🏃🦀/cool/index.html?amazing=1#right%20here");
917 test_iri!(4, 20; "news://例🏃🦀/cool.news");
918 test_iri!(5, 20; "git://例/cool.git");
919 test_iri!(5, 20; "ssh://user@somewhere.over.here:12345/例🏃🦀/cool.git");
920 test_iri!(7, 20; "ftp://例🏃🦀/cool.ftp");
921 }
922
923 // There are likely more tests needed for IRI vs URI
924 #[test]
925 fn iris() {
926 // These refer to the same location, see example here:
927 // <https://en.wikipedia.org/wiki/Internationalized_Resource_Identifier#Compatibility>
928 test_iri!("https://en.wiktionary.org/wiki/Ῥόδος"); // IRI
929 test_iri!("https://en.wiktionary.org/wiki/%E1%BF%AC%CF%8C%CE%B4%CE%BF%CF%82"); // URI
930 }
931
932 #[test]
933 #[should_panic(expected = "Expected a path, but was a iri")]
934 fn file_is_a_path() {
935 test_iri!("file://test/cool/index.rs");
936 }
937 }
938
939 #[derive(Debug, PartialEq)]
940 enum HyperlinkKind {
941 FileIri,
942 Iri,
943 Path,
944 }
945
946 struct ExpectedHyperlink {
947 hovered_grid_point: AlacPoint,
948 hovered_char: char,
949 hyperlink_kind: HyperlinkKind,
950 iri_or_path: String,
951 row: Option<u32>,
952 column: Option<u32>,
953 hyperlink_match: RangeInclusive<AlacPoint>,
954 }
955
956 /// Converts to Windows style paths on Windows, like path!(), but at runtime for improved test
957 /// readability.
958 fn build_term_from_test_lines<'a>(
959 hyperlink_kind: HyperlinkKind,
960 term_size: TermSize,
961 test_lines: impl Iterator<Item = &'a str>,
962 ) -> (Term<VoidListener>, ExpectedHyperlink) {
963 #[derive(Default, Eq, PartialEq)]
964 enum HoveredState {
965 #[default]
966 HoveredScan,
967 HoveredNextChar,
968 Done,
969 }
970
971 #[derive(Default, Eq, PartialEq)]
972 enum MatchState {
973 #[default]
974 MatchScan,
975 MatchNextChar,
976 Match(AlacPoint),
977 Done,
978 }
979
980 #[derive(Default, Eq, PartialEq)]
981 enum CapturesState {
982 #[default]
983 PathScan,
984 PathNextChar,
985 Path(AlacPoint),
986 RowScan,
987 Row(String),
988 ColumnScan,
989 Column(String),
990 Done,
991 }
992
993 fn prev_input_point_from_term(term: &Term<VoidListener>) -> AlacPoint {
994 let grid = term.grid();
995 let cursor = &grid.cursor;
996 let mut point = cursor.point;
997
998 if !cursor.input_needs_wrap {
999 point.column -= 1;
1000 }
1001
1002 if grid.index(point).flags.contains(Flags::WIDE_CHAR_SPACER) {
1003 point.column -= 1;
1004 }
1005
1006 point
1007 }
1008
1009 let mut hovered_grid_point: Option<AlacPoint> = None;
1010 let mut hyperlink_match = AlacPoint::default()..=AlacPoint::default();
1011 let mut iri_or_path = String::default();
1012 let mut row = None;
1013 let mut column = None;
1014 let mut prev_input_point = AlacPoint::default();
1015 let mut hovered_state = HoveredState::default();
1016 let mut match_state = MatchState::default();
1017 let mut captures_state = CapturesState::default();
1018 let mut term = Term::new(Config::default(), &term_size, VoidListener);
1019
1020 for text in test_lines {
1021 let chars: Box<dyn Iterator<Item = char>> =
1022 if cfg!(windows) && hyperlink_kind == HyperlinkKind::Path {
1023 Box::new(text.chars().map(|c| if c == '/' { '\\' } else { c })) as _
1024 } else {
1025 Box::new(text.chars()) as _
1026 };
1027 let mut chars = chars.peekable();
1028 while let Some(c) = chars.next() {
1029 match c {
1030 '👉' => {
1031 hovered_state = HoveredState::HoveredNextChar;
1032 }
1033 '👈' => {
1034 hovered_grid_point = Some(prev_input_point.add(&term, Boundary::Grid, 1));
1035 }
1036 '«' | '»' => {
1037 captures_state = match captures_state {
1038 CapturesState::PathScan => CapturesState::PathNextChar,
1039 CapturesState::PathNextChar => {
1040 panic!("Should have been handled by char input")
1041 }
1042 CapturesState::Path(start_point) => {
1043 iri_or_path = term.bounds_to_string(start_point, prev_input_point);
1044 CapturesState::RowScan
1045 }
1046 CapturesState::RowScan => CapturesState::Row(String::new()),
1047 CapturesState::Row(number) => {
1048 row = Some(number.parse::<u32>().unwrap());
1049 CapturesState::ColumnScan
1050 }
1051 CapturesState::ColumnScan => CapturesState::Column(String::new()),
1052 CapturesState::Column(number) => {
1053 column = Some(number.parse::<u32>().unwrap());
1054 CapturesState::Done
1055 }
1056 CapturesState::Done => {
1057 panic!("Extra '«', '»'")
1058 }
1059 }
1060 }
1061 '‹' | '›' => {
1062 match_state = match match_state {
1063 MatchState::MatchScan => MatchState::MatchNextChar,
1064 MatchState::MatchNextChar => {
1065 panic!("Should have been handled by char input")
1066 }
1067 MatchState::Match(start_point) => {
1068 hyperlink_match = start_point..=prev_input_point;
1069 MatchState::Done
1070 }
1071 MatchState::Done => {
1072 panic!("Extra '‹', '›'")
1073 }
1074 }
1075 }
1076 _ => {
1077 if let CapturesState::Row(number) | CapturesState::Column(number) =
1078 &mut captures_state
1079 {
1080 number.push(c)
1081 }
1082
1083 let is_windows_abs_path_start = captures_state
1084 == CapturesState::PathNextChar
1085 && cfg!(windows)
1086 && hyperlink_kind == HyperlinkKind::Path
1087 && c == '\\'
1088 && chars.peek().is_some_and(|c| *c != '\\');
1089
1090 if is_windows_abs_path_start {
1091 // Convert Unix abs path start into Windows abs path start so that the
1092 // same test can be used for both OSes.
1093 term.input('C');
1094 prev_input_point = prev_input_point_from_term(&term);
1095 term.input(':');
1096 term.input(c);
1097 } else {
1098 term.input(c);
1099 prev_input_point = prev_input_point_from_term(&term);
1100 }
1101
1102 if hovered_state == HoveredState::HoveredNextChar {
1103 hovered_grid_point = Some(prev_input_point);
1104 hovered_state = HoveredState::Done;
1105 }
1106 if captures_state == CapturesState::PathNextChar {
1107 captures_state = CapturesState::Path(prev_input_point);
1108 }
1109 if match_state == MatchState::MatchNextChar {
1110 match_state = MatchState::Match(prev_input_point);
1111 }
1112 }
1113 }
1114 }
1115 term.move_down_and_cr(1);
1116 }
1117
1118 if hyperlink_kind == HyperlinkKind::FileIri {
1119 let Ok(url) = Url::parse(&iri_or_path) else {
1120 panic!("Failed to parse file IRI `{iri_or_path}`");
1121 };
1122 let Ok(path) = url.to_file_path() else {
1123 panic!("Failed to interpret file IRI `{iri_or_path}` as a path");
1124 };
1125 iri_or_path = path.to_string_lossy().to_string();
1126 }
1127
1128 if cfg!(windows) {
1129 // Handle verbatim and UNC paths for Windows
1130 if let Some(stripped) = iri_or_path.strip_prefix(r#"\\?\UNC\"#) {
1131 iri_or_path = format!(r#"\\{stripped}"#);
1132 } else if let Some(stripped) = iri_or_path.strip_prefix(r#"\\?\"#) {
1133 iri_or_path = stripped.to_string();
1134 }
1135 }
1136
1137 let hovered_grid_point = hovered_grid_point.expect("Missing hovered point (👉 or 👈)");
1138 let hovered_char = term.grid().index(hovered_grid_point).c;
1139 (
1140 term,
1141 ExpectedHyperlink {
1142 hovered_grid_point,
1143 hovered_char,
1144 hyperlink_kind,
1145 iri_or_path,
1146 row,
1147 column,
1148 hyperlink_match,
1149 },
1150 )
1151 }
1152
1153 fn line_cells_count(line: &str) -> usize {
1154 // This avoids taking a dependency on the unicode-width crate
1155 fn width(c: char) -> usize {
1156 match c {
1157 // Fullwidth unicode characters used in tests
1158 '例' | '🏃' | '🦀' | '🔥' => 2,
1159 _ => 1,
1160 }
1161 }
1162 const CONTROL_CHARS: &str = "‹«👉👈»›";
1163 line.chars()
1164 .filter(|c| !CONTROL_CHARS.contains(*c))
1165 .map(width)
1166 .sum::<usize>()
1167 }
1168
1169 struct CheckHyperlinkMatch<'a> {
1170 term: &'a Term<VoidListener>,
1171 expected_hyperlink: &'a ExpectedHyperlink,
1172 source_location: &'a str,
1173 }
1174
1175 impl<'a> CheckHyperlinkMatch<'a> {
1176 fn new(
1177 term: &'a Term<VoidListener>,
1178 expected_hyperlink: &'a ExpectedHyperlink,
1179 source_location: &'a str,
1180 ) -> Self {
1181 Self {
1182 term,
1183 expected_hyperlink,
1184 source_location,
1185 }
1186 }
1187
1188 fn check_path_with_position_and_match(
1189 &self,
1190 path_with_position: PathWithPosition,
1191 hyperlink_match: &Match,
1192 ) {
1193 let format_path_with_position_and_match =
1194 |path_with_position: &PathWithPosition, hyperlink_match: &Match| {
1195 let mut result =
1196 format!("Path = «{}»", &path_with_position.path.to_string_lossy());
1197 if let Some(row) = path_with_position.row {
1198 result += &format!(", line = {row}");
1199 if let Some(column) = path_with_position.column {
1200 result += &format!(", column = {column}");
1201 }
1202 }
1203
1204 result += &format!(
1205 ", at grid cells {}",
1206 Self::format_hyperlink_match(hyperlink_match)
1207 );
1208 result
1209 };
1210
1211 assert_ne!(
1212 self.expected_hyperlink.hyperlink_kind,
1213 HyperlinkKind::Iri,
1214 "\n at {}\nExpected a path, but was a iri:\n{}",
1215 self.source_location,
1216 self.format_renderable_content()
1217 );
1218
1219 assert_eq!(
1220 format_path_with_position_and_match(
1221 &PathWithPosition {
1222 path: PathBuf::from(self.expected_hyperlink.iri_or_path.clone()),
1223 row: self.expected_hyperlink.row,
1224 column: self.expected_hyperlink.column
1225 },
1226 &self.expected_hyperlink.hyperlink_match
1227 ),
1228 format_path_with_position_and_match(&path_with_position, hyperlink_match),
1229 "\n at {}:\n{}",
1230 self.source_location,
1231 self.format_renderable_content()
1232 );
1233 }
1234
1235 fn check_iri_and_match(&self, iri: String, hyperlink_match: &Match) {
1236 let format_iri_and_match = |iri: &String, hyperlink_match: &Match| {
1237 format!(
1238 "Url = «{iri}», at grid cells {}",
1239 Self::format_hyperlink_match(hyperlink_match)
1240 )
1241 };
1242
1243 assert_eq!(
1244 self.expected_hyperlink.hyperlink_kind,
1245 HyperlinkKind::Iri,
1246 "\n at {}\nExpected a iri, but was a path:\n{}",
1247 self.source_location,
1248 self.format_renderable_content()
1249 );
1250
1251 assert_eq!(
1252 format_iri_and_match(
1253 &self.expected_hyperlink.iri_or_path,
1254 &self.expected_hyperlink.hyperlink_match
1255 ),
1256 format_iri_and_match(&iri, hyperlink_match),
1257 "\n at {}:\n{}",
1258 self.source_location,
1259 self.format_renderable_content()
1260 );
1261 }
1262
1263 fn format_hyperlink_match(hyperlink_match: &Match) -> String {
1264 format!(
1265 "({}, {})..=({}, {})",
1266 hyperlink_match.start().line.0,
1267 hyperlink_match.start().column.0,
1268 hyperlink_match.end().line.0,
1269 hyperlink_match.end().column.0
1270 )
1271 }
1272
1273 fn format_renderable_content(&self) -> String {
1274 let mut result = format!("\nHovered on '{}'\n", self.expected_hyperlink.hovered_char);
1275
1276 let mut first_header_row = String::new();
1277 let mut second_header_row = String::new();
1278 let mut marker_header_row = String::new();
1279 for index in 0..self.term.columns() {
1280 let remainder = index % 10;
1281 first_header_row.push_str(
1282 &(index > 0 && remainder == 0)
1283 .then_some((index / 10).to_string())
1284 .unwrap_or(" ".into()),
1285 );
1286 second_header_row += &remainder.to_string();
1287 if index == self.expected_hyperlink.hovered_grid_point.column.0 {
1288 marker_header_row.push('↓');
1289 } else {
1290 marker_header_row.push(' ');
1291 }
1292 }
1293
1294 result += &format!("\n [{}]\n", first_header_row);
1295 result += &format!(" [{}]\n", second_header_row);
1296 result += &format!(" {}", marker_header_row);
1297
1298 let spacers: Flags = Flags::LEADING_WIDE_CHAR_SPACER | Flags::WIDE_CHAR_SPACER;
1299 for cell in self
1300 .term
1301 .renderable_content()
1302 .display_iter
1303 .filter(|cell| !cell.flags.intersects(spacers))
1304 {
1305 if cell.point.column.0 == 0 {
1306 let prefix =
1307 if cell.point.line == self.expected_hyperlink.hovered_grid_point.line {
1308 '→'
1309 } else {
1310 ' '
1311 };
1312 result += &format!("\n{prefix}[{:>3}] ", cell.point.line.to_string());
1313 }
1314
1315 result.push(cell.c);
1316 }
1317
1318 result
1319 }
1320 }
1321
1322 fn test_hyperlink<'a>(
1323 columns: usize,
1324 total_cells: usize,
1325 test_lines: impl Iterator<Item = &'a str>,
1326 hyperlink_kind: HyperlinkKind,
1327 source_location: &str,
1328 ) {
1329 thread_local! {
1330 static TEST_REGEX_SEARCHES: RefCell<RegexSearches> = RefCell::new(RegexSearches::new());
1331 }
1332
1333 let term_size = TermSize::new(columns, total_cells / columns + 2);
1334 let (term, expected_hyperlink) =
1335 build_term_from_test_lines(hyperlink_kind, term_size, test_lines);
1336 let hyperlink_found = TEST_REGEX_SEARCHES.with(|regex_searches| {
1337 find_from_grid_point(
1338 &term,
1339 expected_hyperlink.hovered_grid_point,
1340 &mut regex_searches.borrow_mut(),
1341 )
1342 });
1343 let check_hyperlink_match =
1344 CheckHyperlinkMatch::new(&term, &expected_hyperlink, source_location);
1345 match hyperlink_found {
1346 Some((hyperlink_word, false, hyperlink_match)) => {
1347 check_hyperlink_match.check_path_with_position_and_match(
1348 PathWithPosition::parse_str(&hyperlink_word),
1349 &hyperlink_match,
1350 );
1351 }
1352 Some((hyperlink_word, true, hyperlink_match)) => {
1353 check_hyperlink_match.check_iri_and_match(hyperlink_word, &hyperlink_match);
1354 }
1355 _ => {
1356 assert!(
1357 false,
1358 "No hyperlink found\n at {source_location}:\n{}",
1359 check_hyperlink_match.format_renderable_content()
1360 )
1361 }
1362 }
1363 }
1364}