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        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}