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, Point as AlacPoint},
 265        term::{Config, cell::Flags, 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:expr),+ ]; $total_cells:expr; $lines:expr; $hyperlink_kind:ident) => { {
 472            use crate::terminal_hyperlinks::tests::{ test_hyperlink, HyperlinkKind };
 473
 474            let source_location = format!("{}:{}", std::file!(), std::line!());
 475            for columns in vec![ $($columns),+] {
 476                test_hyperlink(columns, $total_cells, $lines, HyperlinkKind::$hyperlink_kind,
 477                    &source_location);
 478            }
 479        } };
 480    }
 481
 482    mod path {
 483        /// 👉 := **hovered** on following char
 484        ///
 485        /// 👈 := **hovered** on wide char spacer of previous full width char
 486        ///
 487        /// **`‹›`** := expected **hyperlink** match
 488        ///
 489        /// **`«»`** := expected **path**, **row**, and **column** capture groups
 490        ///
 491        /// [**`c₀, c₁, …, cₙ;`**]ₒₚₜ := use specified terminal widths of `c₀, c₁, …, cₙ` **columns**
 492        /// (defaults to `3, longest_line_cells / 2, longest_line_cells + 1;`)
 493        ///
 494        macro_rules! test_path {
 495            ($($lines:literal),+) => { test_hyperlink!($($lines),+; Path) };
 496        }
 497
 498        #[test]
 499        fn simple() {
 500            // Rust paths
 501            // Just the path
 502            test_path!("‹«/👉test/cool.rs»›");
 503            test_path!("‹«/test/cool👉.rs»›");
 504
 505            // path and line
 506            test_path!("‹«/👉test/cool.rs»:«4»›");
 507            test_path!("‹«/test/cool.rs»👉:«4»›");
 508            test_path!("‹«/test/cool.rs»:«👉4»›");
 509            test_path!("‹«/👉test/cool.rs»(«4»)›");
 510            test_path!("‹«/test/cool.rs»👉(«4»)›");
 511            test_path!("‹«/test/cool.rs»(«👉4»)›");
 512            test_path!("‹«/test/cool.rs»(«4»👉)›");
 513
 514            // path, line, and column
 515            test_path!("‹«/👉test/cool.rs»:«4»:«2»›");
 516            test_path!("‹«/test/cool.rs»:«4»:«👉2»›");
 517            test_path!("‹«/👉test/cool.rs»(«4»,«2»)›");
 518            test_path!("‹«/test/cool.rs»(«4»👉,«2»)›");
 519
 520            // path, line, column, and ':' suffix
 521            test_path!("‹«/👉test/cool.rs»:«4»:«2»›:");
 522            test_path!("‹«/test/cool.rs»:«4»:«👉2»›:");
 523            test_path!("‹«/👉test/cool.rs»(«4»,«2»)›:");
 524            test_path!("‹«/test/cool.rs»(«4»,«2»👉)›:");
 525
 526            // path, line, column, and description
 527            test_path!("‹«/test/cool.rs»:«4»:«2»›👉:Error!");
 528            test_path!("‹«/test/cool.rs»:«4»:«2»›:👉Error!");
 529            test_path!("‹«/test/co👉ol.rs»(«4»,«2»)›:Error!");
 530
 531            // Cargo output
 532            test_path!("    Compiling Cool 👉(‹«/test/Cool»›)");
 533            test_path!("    Compiling Cool (‹«/👉test/Cool»›)");
 534            test_path!("    Compiling Cool (‹«/test/Cool»›👉)");
 535
 536            // Python
 537            test_path!("‹«awe👉some.py»›");
 538
 539            test_path!("    ‹F👉ile \"«/awesome.py»\", line «42»›: Wat?");
 540            test_path!("    ‹File \"«/awe👉some.py»\", line «42»›: Wat?");
 541            test_path!("    ‹File \"«/awesome.py»👉\", line «42»›: Wat?");
 542            test_path!("    ‹File \"«/awesome.py»\", line «4👉2»›: Wat?");
 543        }
 544
 545        #[test]
 546        fn colons_galore() {
 547            test_path!("‹«/test/co👉ol.rs»:«4»›");
 548            test_path!("‹«/test/co👉ol.rs»:«4»›:");
 549            test_path!("‹«/test/co👉ol.rs»:«4»:«2»›");
 550            test_path!("‹«/test/co👉ol.rs»:«4»:«2»›:");
 551            test_path!("‹«/test/co👉ol.rs»(«1»)›");
 552            test_path!("‹«/test/co👉ol.rs»(«1»)›:");
 553            test_path!("‹«/test/co👉ol.rs»(«1»,«618»)›");
 554            test_path!("‹«/test/co👉ol.rs»(«1»,«618»)›:");
 555            test_path!("‹«/test/co👉ol.rs»::«42»›");
 556            test_path!("‹«/test/co👉ol.rs»::«42»›:");
 557            test_path!("‹«/test/co👉ol.rs:4:2»(«1»,«618»)›");
 558            test_path!("‹«/test/co👉ol.rs»(«1»,«618»)›::");
 559        }
 560
 561        #[test]
 562        fn quotes_and_brackets() {
 563            test_path!("\"‹«/test/co👉ol.rs»:«4»›\"");
 564            test_path!("'‹«/test/co👉ol.rs»:«4»›'");
 565            test_path!("`‹«/test/co👉ol.rs»:«4»›`");
 566
 567            test_path!("[‹«/test/co👉ol.rs»:«4»›]");
 568            test_path!("(‹«/test/co👉ol.rs»:«4»›)");
 569            test_path!("{‹«/test/co👉ol.rs»:«4»›}");
 570            test_path!("<‹«/test/co👉ol.rs»:«4»›>");
 571
 572            test_path!("[\"‹«/test/co👉ol.rs»:«4»›\"]");
 573            test_path!("'(‹«/test/co👉ol.rs»:«4»›)'");
 574        }
 575
 576        #[test]
 577        fn word_wide_chars() {
 578            // Rust paths
 579            test_path!("‹«/👉例/cool.rs»›");
 580            test_path!("‹«/例👈/cool.rs»›");
 581            test_path!("‹«/例/cool.rs»:«👉4»›");
 582            test_path!("‹«/例/cool.rs»:«4»:«👉2»›");
 583
 584            // Cargo output
 585            test_path!("    Compiling Cool (‹«/👉例/Cool»›)");
 586            test_path!("    Compiling Cool (‹«/例👈/Cool»›)");
 587
 588            // Python
 589            test_path!("‹«👉例wesome.py»›");
 590            test_path!("‹«例👈wesome.py»›");
 591            test_path!("    ‹File \"«/👉例wesome.py»\", line «42»›: Wat?");
 592            test_path!("    ‹File \"«/例👈wesome.py»\", line «42»›: Wat?");
 593        }
 594
 595        #[test]
 596        fn non_word_wide_chars() {
 597            // Mojo diagnostic message
 598            test_path!("    ‹File \"«/awe👉some.🔥»\", line «42»›: Wat?");
 599            test_path!("    ‹File \"«/awesome👉.🔥»\", line «42»›: Wat?");
 600            test_path!("    ‹File \"«/awesome.👉🔥»\", line «42»›: Wat?");
 601            test_path!("    ‹File \"«/awesome.🔥👈»\", line «42»›: Wat?");
 602        }
 603
 604        /// These likely rise to the level of being worth fixing.
 605        mod issues {
 606            #[test]
 607            // <https://github.com/alacritty/alacritty/issues/8586>
 608            fn issue_alacritty_8586() {
 609                // Rust paths
 610                test_path!("‹«/👉例/cool.rs»›");
 611                test_path!("‹«/例👈/cool.rs»›");
 612                test_path!("‹«/例/cool.rs»:«👉4»›");
 613                test_path!("‹«/例/cool.rs»:«4»:«👉2»›");
 614
 615                // Cargo output
 616                test_path!("    Compiling Cool (‹«/👉例/Cool»›)");
 617                test_path!("    Compiling Cool (‹«/例👈/Cool»›)");
 618
 619                // Python
 620                test_path!("‹«👉例wesome.py»›");
 621                test_path!("‹«例👈wesome.py»›");
 622                test_path!("    ‹File \"«/👉例wesome.py»\", line «42»›: Wat?");
 623                test_path!("    ‹File \"«/例👈wesome.py»\", line «42»›: Wat?");
 624            }
 625
 626            #[test]
 627            #[should_panic(expected = "No hyperlink found")]
 628            // <https://github.com/zed-industries/zed/issues/12338>
 629            fn issue_12338() {
 630                // Issue #12338
 631                test_path!(".rw-r--r--     0     staff 05-27 14:03 ‹«test👉、2.txt»›");
 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👉。3.txt»›");
 634                test_path!(".rw-r--r--     0     staff 05-27 14:03 ‹«test。👈3.txt»›");
 635
 636                // Rust paths
 637                test_path!("‹«/👉🏃/🦀.rs»›");
 638                test_path!("‹«/🏃👈/🦀.rs»›");
 639                test_path!("‹«/🏃/👉🦀.rs»:«4»›");
 640                test_path!("‹«/🏃/🦀👈.rs»:«4»:«2»›");
 641
 642                // Cargo output
 643                test_path!("    Compiling Cool (‹«/👉🏃/Cool»›)");
 644                test_path!("    Compiling Cool (‹«/🏃👈/Cool»›)");
 645
 646                // Python
 647                test_path!("‹«👉🏃wesome.py»›");
 648                test_path!("‹«🏃👈wesome.py»›");
 649                test_path!("    ‹File \"«/👉🏃wesome.py»\", line «42»›: Wat?");
 650                test_path!("    ‹File \"«/🏃👈wesome.py»\", line «42»›: Wat?");
 651
 652                // Mojo
 653                test_path!("‹«/awe👉some.🔥»› is some good Mojo!");
 654                test_path!("‹«/awesome👉.🔥»› is some good Mojo!");
 655                test_path!("‹«/awesome.👉🔥»› is some good Mojo!");
 656                test_path!("‹«/awesome.🔥👈»› is some good Mojo!");
 657                test_path!("    ‹File \"«/👉🏃wesome.🔥»\", line «42»›: Wat?");
 658                test_path!("    ‹File \"«/🏃👈wesome.🔥»\", line «42»›: Wat?");
 659            }
 660
 661            #[test]
 662            #[cfg_attr(
 663                not(target_os = "windows"),
 664                should_panic(
 665                    expected = "Path = «test/controllers/template_items_controller_test.rb», line = 20, at grid cells (0, 0)..=(17, 1)"
 666                )
 667            )]
 668            #[cfg_attr(
 669                target_os = "windows",
 670                should_panic(
 671                    expected = r#"Path = «test\\controllers\\template_items_controller_test.rb», line = 20, at grid cells (0, 0)..=(17, 1)"#
 672                )
 673            )]
 674            // <https://github.com/zed-industries/zed/issues/28194>
 675            //
 676            // #28194 was closed, but the link includes the description part (":in" here), which
 677            // seems wrong...
 678            fn issue_28194() {
 679                test_path!(
 680                    "‹«test/c👉ontrollers/template_items_controller_test.rb»:«20»›:in 'block (2 levels) in <class:TemplateItemsControllerTest>'"
 681                );
 682                test_path!(
 683                    "‹«test/controllers/template_items_controller_test.rb»:«19»›:i👉n 'block in <class:TemplateItemsControllerTest>'"
 684                );
 685            }
 686        }
 687
 688        /// Minor issues arguably not important enough to fix/workaround...
 689        mod nits {
 690            #[test]
 691            fn alacritty_bugs_with_two_columns() {
 692                test_path!("‹«/👉test/cool.rs»(«4»)›");
 693                test_path!("‹«/test/cool.rs»(«👉4»)›");
 694                test_path!("‹«/test/cool.rs»(«4»,«👉2»)›");
 695
 696                // Python
 697                test_path!("‹«awe👉some.py»›");
 698            }
 699
 700            #[test]
 701            #[cfg_attr(
 702                not(target_os = "windows"),
 703                should_panic(
 704                    expected = "Path = «/test/cool.rs», line = 1, at grid cells (0, 0)..=(9, 0)"
 705                )
 706            )]
 707            #[cfg_attr(
 708                target_os = "windows",
 709                should_panic(
 710                    expected = r#"Path = «C:\\test\\cool.rs», line = 1, at grid cells (0, 0)..=(9, 2)"#
 711                )
 712            )]
 713            fn invalid_row_column_should_be_part_of_path() {
 714                test_path!("‹«/👉test/cool.rs:1:618033988749»›");
 715                test_path!("‹«/👉test/cool.rs(1,618033988749)»›");
 716            }
 717
 718            #[test]
 719            #[should_panic(expected = "Path = «»")]
 720            fn colon_suffix_succeeds_in_finding_an_empty_maybe_path() {
 721                test_path!("‹«/test/cool.rs»:«4»:«2»›👉:", "What is this?");
 722                test_path!("‹«/test/cool.rs»(«4»,«2»)›👉:", "What is this?");
 723            }
 724
 725            #[test]
 726            #[cfg_attr(
 727                not(target_os = "windows"),
 728                should_panic(expected = "Path = «/test/cool.rs»")
 729            )]
 730            #[cfg_attr(
 731                target_os = "windows",
 732                should_panic(expected = r#"Path = «C:\\test\\cool.rs»"#)
 733            )]
 734            fn many_trailing_colons_should_be_parsed_as_part_of_the_path() {
 735                test_path!("‹«/test/cool.rs:::👉:»›");
 736                test_path!("‹«/te:st/👉co:ol.r:s:4:2::::::»›");
 737            }
 738        }
 739
 740        #[cfg(target_os = "windows")]
 741        mod windows {
 742            // Lots of fun to be had with long file paths (verbatim) and UNC paths on Windows.
 743            // See <https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation>
 744            // See <https://users.rust-lang.org/t/understanding-windows-paths/58583>
 745            // See <https://github.com/rust-lang/cargo/issues/13919>
 746
 747            #[test]
 748            fn unc() {
 749                test_path!(r#"‹«\\server\share\👉test\cool.rs»›"#);
 750                test_path!(r#"‹«\\server\share\test\cool👉.rs»›"#);
 751            }
 752
 753            mod issues {
 754                #[test]
 755                #[should_panic(
 756                    expected = r#"Path = «C:\\test\\cool.rs», at grid cells (0, 0)..=(6, 0)"#
 757                )]
 758                fn issue_verbatim() {
 759                    test_path!(r#"‹«\\?\C:\👉test\cool.rs»›"#);
 760                    test_path!(r#"‹«\\?\C:\test\cool👉.rs»›"#);
 761                }
 762
 763                #[test]
 764                #[should_panic(
 765                    expected = r#"Path = «\\\\server\\share\\test\\cool.rs», at grid cells (0, 0)..=(10, 2)"#
 766                )]
 767                fn issue_verbatim_unc() {
 768                    test_path!(r#"‹«\\?\UNC\server\share\👉test\cool.rs»›"#);
 769                    test_path!(r#"‹«\\?\UNC\server\share\test\cool👉.rs»›"#);
 770                }
 771            }
 772        }
 773    }
 774
 775    mod file_iri {
 776        // File IRIs have a ton of use cases, most of which we currently do not support. A few of
 777        // those cases are documented here as tests which are expected to fail.
 778        // See https://en.wikipedia.org/wiki/File_URI_scheme
 779
 780        /// [**`c₀, c₁, …, cₙ;`**]ₒₚₜ := use specified terminal widths of `c₀, c₁, …, cₙ` **columns**
 781        /// (defaults to `3, longest_line_cells / 2, longest_line_cells + 1;`)
 782        ///
 783        macro_rules! test_file_iri {
 784            ($file_iri:literal) => { { test_hyperlink!(concat!("‹«👉", $file_iri, "»›"); FileIri) } };
 785        }
 786
 787        #[cfg(not(target_os = "windows"))]
 788        #[test]
 789        fn absolute_file_iri() {
 790            test_file_iri!("file:///test/cool/index.rs");
 791            test_file_iri!("file:///test/cool/");
 792        }
 793
 794        mod issues {
 795            #[cfg(not(target_os = "windows"))]
 796            #[test]
 797            #[should_panic(expected = "Path = «/test/Ῥόδος/», at grid cells (0, 0)..=(15, 1)")]
 798            fn issue_file_iri_with_percent_encoded_characters() {
 799                // Non-space characters
 800                // file:///test/Ῥόδος/
 801                test_file_iri!("file:///test/%E1%BF%AC%CF%8C%CE%B4%CE%BF%CF%82/"); // URI
 802
 803                // Spaces
 804                test_file_iri!("file:///te%20st/co%20ol/index.rs");
 805                test_file_iri!("file:///te%20st/co%20ol/");
 806            }
 807        }
 808
 809        #[cfg(target_os = "windows")]
 810        mod windows {
 811            mod issues {
 812                // The test uses Url::to_file_path(), but it seems that the Url crate doesn't
 813                // support relative file IRIs.
 814                #[test]
 815                #[should_panic(
 816                    expected = r#"Failed to interpret file IRI `file:/test/cool/index.rs` as a path"#
 817                )]
 818                fn issue_relative_file_iri() {
 819                    test_file_iri!("file:/test/cool/index.rs");
 820                    test_file_iri!("file:/test/cool/");
 821                }
 822
 823                // See https://en.wikipedia.org/wiki/File_URI_scheme
 824                #[test]
 825                #[should_panic(
 826                    expected = r#"Path = «C:\\test\\cool\\index.rs», at grid cells (0, 0)..=(9, 1)"#
 827                )]
 828                fn issue_absolute_file_iri() {
 829                    test_file_iri!("file:///C:/test/cool/index.rs");
 830                    test_file_iri!("file:///C:/test/cool/");
 831                }
 832
 833                #[test]
 834                #[should_panic(
 835                    expected = r#"Path = «C:\\test\\Ῥόδος\\», at grid cells (0, 0)..=(16, 1)"#
 836                )]
 837                fn issue_file_iri_with_percent_encoded_characters() {
 838                    // Non-space characters
 839                    // file:///test/Ῥόδος/
 840                    test_file_iri!("file:///C:/test/%E1%BF%AC%CF%8C%CE%B4%CE%BF%CF%82/"); // URI
 841
 842                    // Spaces
 843                    test_file_iri!("file:///C:/te%20st/co%20ol/index.rs");
 844                    test_file_iri!("file:///C:/te%20st/co%20ol/");
 845                }
 846            }
 847        }
 848    }
 849
 850    mod iri {
 851        /// [**`c₀, c₁, …, cₙ;`**]ₒₚₜ := use specified terminal widths of `c₀, c₁, …, cₙ` **columns**
 852        /// (defaults to `3, longest_line_cells / 2, longest_line_cells + 1;`)
 853        ///
 854        macro_rules! test_iri {
 855            ($iri:literal) => { { test_hyperlink!(concat!("‹«👉", $iri, "»›"); Iri) } };
 856        }
 857
 858        #[test]
 859        fn simple() {
 860            // In the order they appear in URL_REGEX, except 'file://' which is treated as a path
 861            test_iri!("ipfs://test/cool.ipfs");
 862            test_iri!("ipns://test/cool.ipns");
 863            test_iri!("magnet://test/cool.git");
 864            test_iri!("mailto:someone@somewhere.here");
 865            test_iri!("gemini://somewhere.here");
 866            test_iri!("gopher://somewhere.here");
 867            test_iri!("http://test/cool/index.html");
 868            test_iri!("http://10.10.10.10:1111/cool.html");
 869            test_iri!("http://test/cool/index.html?amazing=1");
 870            test_iri!("http://test/cool/index.html#right%20here");
 871            test_iri!("http://test/cool/index.html?amazing=1#right%20here");
 872            test_iri!("https://test/cool/index.html");
 873            test_iri!("https://10.10.10.10:1111/cool.html");
 874            test_iri!("https://test/cool/index.html?amazing=1");
 875            test_iri!("https://test/cool/index.html#right%20here");
 876            test_iri!("https://test/cool/index.html?amazing=1#right%20here");
 877            test_iri!("news://test/cool.news");
 878            test_iri!("git://test/cool.git");
 879            test_iri!("ssh://user@somewhere.over.here:12345/test/cool.git");
 880            test_iri!("ftp://test/cool.ftp");
 881        }
 882
 883        #[test]
 884        fn wide_chars() {
 885            // In the order they appear in URL_REGEX, except 'file://' which is treated as a path
 886            test_iri!("ipfs://例🏃🦀/cool.ipfs");
 887            test_iri!("ipns://例🏃🦀/cool.ipns");
 888            test_iri!("magnet://例🏃🦀/cool.git");
 889            test_iri!("mailto:someone@somewhere.here");
 890            test_iri!("gemini://somewhere.here");
 891            test_iri!("gopher://somewhere.here");
 892            test_iri!("http://例🏃🦀/cool/index.html");
 893            test_iri!("http://10.10.10.10:1111/cool.html");
 894            test_iri!("http://例🏃🦀/cool/index.html?amazing=1");
 895            test_iri!("http://例🏃🦀/cool/index.html#right%20here");
 896            test_iri!("http://例🏃🦀/cool/index.html?amazing=1#right%20here");
 897            test_iri!("https://例🏃🦀/cool/index.html");
 898            test_iri!("https://10.10.10.10:1111/cool.html");
 899            test_iri!("https://例🏃🦀/cool/index.html?amazing=1");
 900            test_iri!("https://例🏃🦀/cool/index.html#right%20here");
 901            test_iri!("https://例🏃🦀/cool/index.html?amazing=1#right%20here");
 902            test_iri!("news://例🏃🦀/cool.news");
 903            test_iri!("git://例/cool.git");
 904            test_iri!("ssh://user@somewhere.over.here:12345/例🏃🦀/cool.git");
 905            test_iri!("ftp://例🏃🦀/cool.ftp");
 906        }
 907
 908        // There are likely more tests needed for IRI vs URI
 909        #[test]
 910        fn iris() {
 911            // These refer to the same location, see example here:
 912            // <https://en.wikipedia.org/wiki/Internationalized_Resource_Identifier#Compatibility>
 913            test_iri!("https://en.wiktionary.org/wiki/Ῥόδος"); // IRI
 914            test_iri!("https://en.wiktionary.org/wiki/%E1%BF%AC%CF%8C%CE%B4%CE%BF%CF%82"); // URI
 915        }
 916
 917        #[test]
 918        #[should_panic(expected = "Expected a path, but was a iri")]
 919        fn file_is_a_path() {
 920            test_iri!("file://test/cool/index.rs");
 921        }
 922    }
 923
 924    #[derive(Debug, PartialEq)]
 925    enum HyperlinkKind {
 926        FileIri,
 927        Iri,
 928        Path,
 929    }
 930
 931    struct ExpectedHyperlink {
 932        hovered_grid_point: AlacPoint,
 933        hovered_char: char,
 934        hyperlink_kind: HyperlinkKind,
 935        iri_or_path: String,
 936        row: Option<u32>,
 937        column: Option<u32>,
 938        hyperlink_match: RangeInclusive<AlacPoint>,
 939    }
 940
 941    /// Converts to Windows style paths on Windows, like path!(), but at runtime for improved test
 942    /// readability.
 943    fn build_term_from_test_lines<'a>(
 944        hyperlink_kind: HyperlinkKind,
 945        term_size: TermSize,
 946        test_lines: impl Iterator<Item = &'a str>,
 947    ) -> (Term<VoidListener>, ExpectedHyperlink) {
 948        #[derive(Default, Eq, PartialEq)]
 949        enum HoveredState {
 950            #[default]
 951            HoveredScan,
 952            HoveredNextChar,
 953            Done,
 954        }
 955
 956        #[derive(Default, Eq, PartialEq)]
 957        enum MatchState {
 958            #[default]
 959            MatchScan,
 960            MatchNextChar,
 961            Match(AlacPoint),
 962            Done,
 963        }
 964
 965        #[derive(Default, Eq, PartialEq)]
 966        enum CapturesState {
 967            #[default]
 968            PathScan,
 969            PathNextChar,
 970            Path(AlacPoint),
 971            RowScan,
 972            Row(String),
 973            ColumnScan,
 974            Column(String),
 975            Done,
 976        }
 977
 978        fn prev_input_point_from_term(term: &Term<VoidListener>) -> AlacPoint {
 979            let grid = term.grid();
 980            let cursor = &grid.cursor;
 981            let mut point = cursor.point;
 982
 983            if !cursor.input_needs_wrap {
 984                point.column -= 1;
 985            }
 986
 987            if grid.index(point).flags.contains(Flags::WIDE_CHAR_SPACER) {
 988                point.column -= 1;
 989            }
 990
 991            point
 992        }
 993
 994        fn end_point_from_prev_input_point(
 995            term: &Term<VoidListener>,
 996            prev_input_point: AlacPoint,
 997        ) -> AlacPoint {
 998            if term
 999                .grid()
1000                .index(prev_input_point)
1001                .flags
1002                .contains(Flags::WIDE_CHAR)
1003            {
1004                prev_input_point.add(term, Boundary::Grid, 1)
1005            } else {
1006                prev_input_point
1007            }
1008        }
1009
1010        let mut hovered_grid_point: Option<AlacPoint> = None;
1011        let mut hyperlink_match = AlacPoint::default()..=AlacPoint::default();
1012        let mut iri_or_path = String::default();
1013        let mut row = None;
1014        let mut column = None;
1015        let mut prev_input_point = AlacPoint::default();
1016        let mut hovered_state = HoveredState::default();
1017        let mut match_state = MatchState::default();
1018        let mut captures_state = CapturesState::default();
1019        let mut term = Term::new(Config::default(), &term_size, VoidListener);
1020
1021        for text in test_lines {
1022            let chars: Box<dyn Iterator<Item = char>> =
1023                if cfg!(windows) && hyperlink_kind == HyperlinkKind::Path {
1024                    Box::new(text.chars().map(|c| if c == '/' { '\\' } else { c })) as _
1025                } else {
1026                    Box::new(text.chars()) as _
1027                };
1028            let mut chars = chars.peekable();
1029            while let Some(c) = chars.next() {
1030                match c {
1031                    '👉' => {
1032                        hovered_state = HoveredState::HoveredNextChar;
1033                    }
1034                    '👈' => {
1035                        hovered_grid_point = Some(prev_input_point.add(&term, Boundary::Grid, 1));
1036                    }
1037                    '«' | '»' => {
1038                        captures_state = match captures_state {
1039                            CapturesState::PathScan => CapturesState::PathNextChar,
1040                            CapturesState::PathNextChar => {
1041                                panic!("Should have been handled by char input")
1042                            }
1043                            CapturesState::Path(start_point) => {
1044                                iri_or_path = term.bounds_to_string(
1045                                    start_point,
1046                                    end_point_from_prev_input_point(&term, prev_input_point),
1047                                );
1048                                CapturesState::RowScan
1049                            }
1050                            CapturesState::RowScan => CapturesState::Row(String::new()),
1051                            CapturesState::Row(number) => {
1052                                row = Some(number.parse::<u32>().unwrap());
1053                                CapturesState::ColumnScan
1054                            }
1055                            CapturesState::ColumnScan => CapturesState::Column(String::new()),
1056                            CapturesState::Column(number) => {
1057                                column = Some(number.parse::<u32>().unwrap());
1058                                CapturesState::Done
1059                            }
1060                            CapturesState::Done => {
1061                                panic!("Extra '«', '»'")
1062                            }
1063                        }
1064                    }
1065                    '‹' | '›' => {
1066                        match_state = match match_state {
1067                            MatchState::MatchScan => MatchState::MatchNextChar,
1068                            MatchState::MatchNextChar => {
1069                                panic!("Should have been handled by char input")
1070                            }
1071                            MatchState::Match(start_point) => {
1072                                hyperlink_match = start_point
1073                                    ..=end_point_from_prev_input_point(&term, prev_input_point);
1074                                MatchState::Done
1075                            }
1076                            MatchState::Done => {
1077                                panic!("Extra '‹', '›'")
1078                            }
1079                        }
1080                    }
1081                    _ => {
1082                        if let CapturesState::Row(number) | CapturesState::Column(number) =
1083                            &mut captures_state
1084                        {
1085                            number.push(c)
1086                        }
1087
1088                        let is_windows_abs_path_start = captures_state
1089                            == CapturesState::PathNextChar
1090                            && cfg!(windows)
1091                            && hyperlink_kind == HyperlinkKind::Path
1092                            && c == '\\'
1093                            && chars.peek().is_some_and(|c| *c != '\\');
1094
1095                        if is_windows_abs_path_start {
1096                            // Convert Unix abs path start into Windows abs path start so that the
1097                            // same test can be used for both OSes.
1098                            term.input('C');
1099                            prev_input_point = prev_input_point_from_term(&term);
1100                            term.input(':');
1101                            term.input(c);
1102                        } else {
1103                            term.input(c);
1104                            prev_input_point = prev_input_point_from_term(&term);
1105                        }
1106
1107                        if hovered_state == HoveredState::HoveredNextChar {
1108                            hovered_grid_point = Some(prev_input_point);
1109                            hovered_state = HoveredState::Done;
1110                        }
1111                        if captures_state == CapturesState::PathNextChar {
1112                            captures_state = CapturesState::Path(prev_input_point);
1113                        }
1114                        if match_state == MatchState::MatchNextChar {
1115                            match_state = MatchState::Match(prev_input_point);
1116                        }
1117                    }
1118                }
1119            }
1120            term.move_down_and_cr(1);
1121        }
1122
1123        if hyperlink_kind == HyperlinkKind::FileIri {
1124            let Ok(url) = Url::parse(&iri_or_path) else {
1125                panic!("Failed to parse file IRI `{iri_or_path}`");
1126            };
1127            let Ok(path) = url.to_file_path() else {
1128                panic!("Failed to interpret file IRI `{iri_or_path}` as a path");
1129            };
1130            iri_or_path = path.to_string_lossy().to_string();
1131        }
1132
1133        if cfg!(windows) {
1134            // Handle verbatim and UNC paths for Windows
1135            if let Some(stripped) = iri_or_path.strip_prefix(r#"\\?\UNC\"#) {
1136                iri_or_path = format!(r#"\\{stripped}"#);
1137            } else if let Some(stripped) = iri_or_path.strip_prefix(r#"\\?\"#) {
1138                iri_or_path = stripped.to_string();
1139            }
1140        }
1141
1142        let hovered_grid_point = hovered_grid_point.expect("Missing hovered point (👉 or 👈)");
1143        let hovered_char = term.grid().index(hovered_grid_point).c;
1144        (
1145            term,
1146            ExpectedHyperlink {
1147                hovered_grid_point,
1148                hovered_char,
1149                hyperlink_kind,
1150                iri_or_path,
1151                row,
1152                column,
1153                hyperlink_match,
1154            },
1155        )
1156    }
1157
1158    fn line_cells_count(line: &str) -> usize {
1159        // This avoids taking a dependency on the unicode-width crate
1160        fn width(c: char) -> usize {
1161            match c {
1162                // Fullwidth unicode characters used in tests
1163                '例' | '🏃' | '🦀' | '🔥' => 2,
1164                _ => 1,
1165            }
1166        }
1167        const CONTROL_CHARS: &str = "‹«👉👈»›";
1168        line.chars()
1169            .filter(|c| !CONTROL_CHARS.contains(*c))
1170            .map(width)
1171            .sum::<usize>()
1172    }
1173
1174    struct CheckHyperlinkMatch<'a> {
1175        term: &'a Term<VoidListener>,
1176        expected_hyperlink: &'a ExpectedHyperlink,
1177        source_location: &'a str,
1178    }
1179
1180    impl<'a> CheckHyperlinkMatch<'a> {
1181        fn new(
1182            term: &'a Term<VoidListener>,
1183            expected_hyperlink: &'a ExpectedHyperlink,
1184            source_location: &'a str,
1185        ) -> Self {
1186            Self {
1187                term,
1188                expected_hyperlink,
1189                source_location,
1190            }
1191        }
1192
1193        fn check_path_with_position_and_match(
1194            &self,
1195            path_with_position: PathWithPosition,
1196            hyperlink_match: &Match,
1197        ) {
1198            let format_path_with_position_and_match =
1199                |path_with_position: &PathWithPosition, hyperlink_match: &Match| {
1200                    let mut result =
1201                        format!("Path = «{}»", &path_with_position.path.to_string_lossy());
1202                    if let Some(row) = path_with_position.row {
1203                        result += &format!(", line = {row}");
1204                        if let Some(column) = path_with_position.column {
1205                            result += &format!(", column = {column}");
1206                        }
1207                    }
1208
1209                    result += &format!(
1210                        ", at grid cells {}",
1211                        Self::format_hyperlink_match(hyperlink_match)
1212                    );
1213                    result
1214                };
1215
1216            assert_ne!(
1217                self.expected_hyperlink.hyperlink_kind,
1218                HyperlinkKind::Iri,
1219                "\n    at {}\nExpected a path, but was a iri:\n{}",
1220                self.source_location,
1221                self.format_renderable_content()
1222            );
1223
1224            assert_eq!(
1225                format_path_with_position_and_match(
1226                    &PathWithPosition {
1227                        path: PathBuf::from(self.expected_hyperlink.iri_or_path.clone()),
1228                        row: self.expected_hyperlink.row,
1229                        column: self.expected_hyperlink.column
1230                    },
1231                    &self.expected_hyperlink.hyperlink_match
1232                ),
1233                format_path_with_position_and_match(&path_with_position, hyperlink_match),
1234                "\n    at {}:\n{}",
1235                self.source_location,
1236                self.format_renderable_content()
1237            );
1238        }
1239
1240        fn check_iri_and_match(&self, iri: String, hyperlink_match: &Match) {
1241            let format_iri_and_match = |iri: &String, hyperlink_match: &Match| {
1242                format!(
1243                    "Url = «{iri}», at grid cells {}",
1244                    Self::format_hyperlink_match(hyperlink_match)
1245                )
1246            };
1247
1248            assert_eq!(
1249                self.expected_hyperlink.hyperlink_kind,
1250                HyperlinkKind::Iri,
1251                "\n    at {}\nExpected a iri, but was a path:\n{}",
1252                self.source_location,
1253                self.format_renderable_content()
1254            );
1255
1256            assert_eq!(
1257                format_iri_and_match(
1258                    &self.expected_hyperlink.iri_or_path,
1259                    &self.expected_hyperlink.hyperlink_match
1260                ),
1261                format_iri_and_match(&iri, hyperlink_match),
1262                "\n    at {}:\n{}",
1263                self.source_location,
1264                self.format_renderable_content()
1265            );
1266        }
1267
1268        fn format_hyperlink_match(hyperlink_match: &Match) -> String {
1269            format!(
1270                "({}, {})..=({}, {})",
1271                hyperlink_match.start().line.0,
1272                hyperlink_match.start().column.0,
1273                hyperlink_match.end().line.0,
1274                hyperlink_match.end().column.0
1275            )
1276        }
1277
1278        fn format_renderable_content(&self) -> String {
1279            let mut result = format!("\nHovered on '{}'\n", self.expected_hyperlink.hovered_char);
1280
1281            let mut first_header_row = String::new();
1282            let mut second_header_row = String::new();
1283            let mut marker_header_row = String::new();
1284            for index in 0..self.term.columns() {
1285                let remainder = index % 10;
1286                first_header_row.push_str(
1287                    &(index > 0 && remainder == 0)
1288                        .then_some((index / 10).to_string())
1289                        .unwrap_or(" ".into()),
1290                );
1291                second_header_row += &remainder.to_string();
1292                if index == self.expected_hyperlink.hovered_grid_point.column.0 {
1293                    marker_header_row.push('↓');
1294                } else {
1295                    marker_header_row.push(' ');
1296                }
1297            }
1298
1299            result += &format!("\n      [{}]\n", first_header_row);
1300            result += &format!("      [{}]\n", second_header_row);
1301            result += &format!("       {}", marker_header_row);
1302
1303            let spacers: Flags = Flags::LEADING_WIDE_CHAR_SPACER | Flags::WIDE_CHAR_SPACER;
1304            for cell in self
1305                .term
1306                .renderable_content()
1307                .display_iter
1308                .filter(|cell| !cell.flags.intersects(spacers))
1309            {
1310                if cell.point.column.0 == 0 {
1311                    let prefix =
1312                        if cell.point.line == self.expected_hyperlink.hovered_grid_point.line {
1313                            '→'
1314                        } else {
1315                            ' '
1316                        };
1317                    result += &format!("\n{prefix}[{:>3}] ", cell.point.line.to_string());
1318                }
1319
1320                result.push(cell.c);
1321            }
1322
1323            result
1324        }
1325    }
1326
1327    fn test_hyperlink<'a>(
1328        columns: usize,
1329        total_cells: usize,
1330        test_lines: impl Iterator<Item = &'a str>,
1331        hyperlink_kind: HyperlinkKind,
1332        source_location: &str,
1333    ) {
1334        thread_local! {
1335            static TEST_REGEX_SEARCHES: RefCell<RegexSearches> = RefCell::new(RegexSearches::new());
1336        }
1337
1338        let term_size = TermSize::new(columns, total_cells / columns + 2);
1339        let (term, expected_hyperlink) =
1340            build_term_from_test_lines(hyperlink_kind, term_size, test_lines);
1341        let hyperlink_found = TEST_REGEX_SEARCHES.with(|regex_searches| {
1342            find_from_grid_point(
1343                &term,
1344                expected_hyperlink.hovered_grid_point,
1345                &mut regex_searches.borrow_mut(),
1346            )
1347        });
1348        let check_hyperlink_match =
1349            CheckHyperlinkMatch::new(&term, &expected_hyperlink, source_location);
1350        match hyperlink_found {
1351            Some((hyperlink_word, false, hyperlink_match)) => {
1352                check_hyperlink_match.check_path_with_position_and_match(
1353                    PathWithPosition::parse_str(&hyperlink_word),
1354                    &hyperlink_match,
1355                );
1356            }
1357            Some((hyperlink_word, true, hyperlink_match)) => {
1358                check_hyperlink_match.check_iri_and_match(hyperlink_word, &hyperlink_match);
1359            }
1360            _ => {
1361                assert!(
1362                    false,
1363                    "No hyperlink found\n     at {source_location}:\n{}",
1364                    check_hyperlink_match.format_renderable_content()
1365                )
1366            }
1367        }
1368    }
1369}