terminal_hyperlinks.rs

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