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