terminal_hyperlinks.rs

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