Add new terminal hyperlink tests (#28525)

Dave Waggoner created

Part of #28238

This PR refactors `FindHyperlink` handling and associated code in
`terminal.rs` into its own file for improved testability, and adds
tests.

Release Notes:

- N/A

Change summary

Cargo.lock                                 |    1 
crates/terminal/Cargo.toml                 |    1 
crates/terminal/src/terminal.rs            |  273 -----
crates/terminal/src/terminal_hyperlinks.rs | 1217 ++++++++++++++++++++++++
4 files changed, 1,232 insertions(+), 260 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -15733,6 +15733,7 @@ dependencies = [
  "task",
  "theme",
  "thiserror 2.0.12",
+ "url",
  "util",
  "windows 0.61.1",
  "workspace-hack",

crates/terminal/src/terminal.rs 🔗

@@ -3,6 +3,7 @@ pub mod mappings;
 pub use alacritty_terminal;
 
 mod pty_info;
+mod terminal_hyperlinks;
 pub mod terminal_settings;
 
 use alacritty_terminal::{
@@ -39,11 +40,11 @@ use mappings::mouse::{
 use collections::{HashMap, VecDeque};
 use futures::StreamExt;
 use pty_info::PtyProcessInfo;
-use regex::Regex;
 use serde::{Deserialize, Serialize};
 use settings::Settings;
 use smol::channel::{Receiver, Sender};
 use task::{HideStrategy, Shell, TaskId};
+use terminal_hyperlinks::RegexSearches;
 use terminal_settings::{AlternateScroll, CursorShape, TerminalSettings};
 use theme::{ActiveTheme, Theme};
 use util::{paths::home_dir, truncate_and_trailoff};
@@ -52,10 +53,10 @@ use std::{
     borrow::Cow,
     cmp::{self, min},
     fmt::Display,
-    ops::{Deref, Index, RangeInclusive},
+    ops::{Deref, RangeInclusive},
     path::PathBuf,
     process::ExitStatus,
-    sync::{Arc, LazyLock},
+    sync::Arc,
     time::Duration,
 };
 use thiserror::Error;
@@ -93,7 +94,6 @@ actions!(
 const SCROLL_MULTIPLIER: f32 = 4.;
 #[cfg(not(target_os = "macos"))]
 const SCROLL_MULTIPLIER: f32 = 1.;
-const MAX_SEARCH_LINES: usize = 100;
 const DEBUG_TERMINAL_WIDTH: Pixels = px(500.);
 const DEBUG_TERMINAL_HEIGHT: Pixels = px(30.);
 const DEBUG_CELL_WIDTH: Pixels = px(5.);
@@ -314,25 +314,6 @@ impl Display for TerminalError {
 // https://github.com/alacritty/alacritty/blob/cb3a79dbf6472740daca8440d5166c1d4af5029e/extra/man/alacritty.5.scd?plain=1#L207-L213
 const DEFAULT_SCROLL_HISTORY_LINES: usize = 10_000;
 pub const MAX_SCROLL_HISTORY_LINES: usize = 100_000;
-const 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{-}\^⟨⟩`]+"#;
-// Optional suffix matches MSBuild diagnostic suffixes for path parsing in PathLikeWithPosition
-// https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-diagnostic-format-for-tasks
-const WORD_REGEX: &str =
-    r#"[\$\+\w.\[\]:/\\@\-~()]+(?:\((?:\d+|\d+,\d+)\))|[\$\+\w.\[\]:/\\@\-~()]+"#;
-const PYTHON_FILE_LINE_REGEX: &str = r#"File "(?P<file>[^"]+)", line (?P<line>\d+)"#;
-
-static PYTHON_FILE_LINE_MATCHER: LazyLock<Regex> =
-    LazyLock::new(|| Regex::new(PYTHON_FILE_LINE_REGEX).unwrap());
-
-fn python_extract_path_and_line(input: &str) -> Option<(&str, u32)> {
-    if let Some(captures) = PYTHON_FILE_LINE_MATCHER.captures(input) {
-        let path_part = captures.name("file")?.as_str();
-
-        let line_number: u32 = captures.name("line")?.as_str().parse().ok()?;
-        return Some((path_part, line_number));
-    }
-    None
-}
 
 pub struct TerminalBuilder {
     terminal: Terminal,
@@ -497,9 +478,7 @@ impl TerminalBuilder {
             next_link_id: 0,
             selection_phase: SelectionPhase::Ended,
             // hovered_word: false,
-            url_regex: RegexSearch::new(URL_REGEX).unwrap(),
-            word_regex: RegexSearch::new(WORD_REGEX).unwrap(),
-            python_file_line_regex: RegexSearch::new(PYTHON_FILE_LINE_REGEX).unwrap(),
+            hyperlink_regex_searches: RegexSearches::new(),
             vi_mode_enabled: false,
             is_ssh_terminal,
             python_venv_directory,
@@ -657,9 +636,7 @@ pub struct Terminal {
     scroll_px: Pixels,
     next_link_id: usize,
     selection_phase: SelectionPhase,
-    url_regex: RegexSearch,
-    word_regex: RegexSearch,
-    python_file_line_regex: RegexSearch,
+    hyperlink_regex_searches: RegexSearches,
     task: Option<TaskState>,
     vi_mode_enabled: bool,
     is_ssh_terminal: bool,
@@ -926,122 +903,14 @@ impl Terminal {
                 )
                 .grid_clamp(term, Boundary::Grid);
 
-                let link = term.grid().index(point).hyperlink();
-                let found_word = if link.is_some() {
-                    let mut min_index = point;
-                    loop {
-                        let new_min_index = min_index.sub(term, Boundary::Cursor, 1);
-                        if new_min_index == min_index
-                            || term.grid().index(new_min_index).hyperlink() != link
-                        {
-                            break;
-                        } else {
-                            min_index = new_min_index
-                        }
-                    }
-
-                    let mut max_index = point;
-                    loop {
-                        let new_max_index = max_index.add(term, Boundary::Cursor, 1);
-                        if new_max_index == max_index
-                            || term.grid().index(new_max_index).hyperlink() != link
-                        {
-                            break;
-                        } else {
-                            max_index = new_max_index
-                        }
-                    }
-
-                    let url = link.unwrap().uri().to_owned();
-                    let url_match = min_index..=max_index;
-
-                    Some((url, true, url_match))
-                } else if let Some(url_match) = regex_match_at(term, point, &mut self.url_regex) {
-                    let url = term.bounds_to_string(*url_match.start(), *url_match.end());
-                    Some((url, true, url_match))
-                } else if let Some(python_match) =
-                    regex_match_at(term, point, &mut self.python_file_line_regex)
-                {
-                    let matching_line =
-                        term.bounds_to_string(*python_match.start(), *python_match.end());
-                    python_extract_path_and_line(&matching_line).map(|(file_path, line_number)| {
-                        (format!("{file_path}:{line_number}"), false, python_match)
-                    })
-                } else if let Some(word_match) = regex_match_at(term, point, &mut self.word_regex) {
-                    let file_path = term.bounds_to_string(*word_match.start(), *word_match.end());
-
-                    let (sanitized_match, sanitized_word) = 'sanitize: {
-                        let mut word_match = word_match;
-                        let mut file_path = file_path;
-
-                        if is_path_surrounded_by_common_symbols(&file_path) {
-                            word_match = Match::new(
-                                word_match.start().add(term, Boundary::Grid, 1),
-                                word_match.end().sub(term, Boundary::Grid, 1),
-                            );
-                            file_path = file_path[1..file_path.len() - 1].to_owned();
-                        }
-
-                        while file_path.ends_with(':') {
-                            file_path.pop();
-                            word_match = Match::new(
-                                *word_match.start(),
-                                word_match.end().sub(term, Boundary::Grid, 1),
-                            );
-                        }
-                        let mut colon_count = 0;
-                        for c in file_path.chars() {
-                            if c == ':' {
-                                colon_count += 1;
-                            }
-                        }
-                        // strip trailing comment after colon in case of
-                        // file/at/path.rs:row:column:description or error message
-                        // so that the file path is `file/at/path.rs:row:column`
-                        if colon_count > 2 {
-                            let last_index = file_path.rfind(':').unwrap();
-                            let prev_is_digit = last_index > 0
-                                && file_path
-                                    .chars()
-                                    .nth(last_index - 1)
-                                    .map_or(false, |c| c.is_ascii_digit());
-                            let next_is_digit = last_index < file_path.len() - 1
-                                && file_path
-                                    .chars()
-                                    .nth(last_index + 1)
-                                    .map_or(true, |c| c.is_ascii_digit());
-                            if prev_is_digit && !next_is_digit {
-                                let stripped_len = file_path.len() - last_index;
-                                word_match = Match::new(
-                                    *word_match.start(),
-                                    word_match.end().sub(term, Boundary::Grid, stripped_len),
-                                );
-                                file_path = file_path[0..last_index].to_owned();
-                            }
-                        }
-
-                        break 'sanitize (word_match, file_path);
-                    };
-
-                    Some((sanitized_word, false, sanitized_match))
-                } else {
-                    None
-                };
-
-                match found_word {
+                match terminal_hyperlinks::find_from_grid_point(
+                    term,
+                    point,
+                    &mut self.hyperlink_regex_searches,
+                ) {
                     Some((maybe_url_or_path, is_url, url_match)) => {
                         let target = if is_url {
-                            // Treat "file://" URLs like file paths to ensure
-                            // that line numbers at the end of the path are
-                            // handled correctly
-                            if let Some(path) = maybe_url_or_path.strip_prefix("file://") {
-                                MaybeNavigationTarget::PathLike(PathLikeTarget {
-                                    maybe_path: path.to_string(),
-                                    terminal_dir: self.working_directory(),
-                                })
-                            } else {
-                                MaybeNavigationTarget::Url(maybe_url_or_path.clone())
-                            }
+                            MaybeNavigationTarget::Url(maybe_url_or_path.clone())
                         } else {
                             MaybeNavigationTarget::PathLike(PathLikeTarget {
                                 maybe_path: maybe_url_or_path.clone(),
@@ -1954,14 +1823,6 @@ pub fn row_to_string(row: &Row<Cell>) -> String {
         .collect::<String>()
 }
 
-fn is_path_surrounded_by_common_symbols(path: &str) -> bool {
-    // Avoid detecting `[]` or `()` strings as paths, surrounded by common symbols
-    path.len() > 2
-        // The rest of the brackets and various quotes cannot be matched by the [`WORD_REGEX`] hence not checked for.
-        && (path.starts_with('[') && path.ends_with(']')
-            || path.starts_with('(') && path.ends_with(')'))
-}
-
 const TASK_DELIMITER: &str = "⏵ ";
 fn task_summary(task: &TaskState, error_code: Option<i32>) -> (bool, String, String) {
     let escaped_full_label = task.full_label.replace("\r\n", "\r").replace('\n', "\r");
@@ -2031,30 +1892,6 @@ impl Drop for Terminal {
 
 impl EventEmitter<Event> for Terminal {}
 
-/// Based on alacritty/src/display/hint.rs > regex_match_at
-/// Retrieve the match, if the specified point is inside the content matching the regex.
-fn regex_match_at<T>(term: &Term<T>, point: AlacPoint, regex: &mut RegexSearch) -> Option<Match> {
-    visible_regex_match_iter(term, regex).find(|rm| rm.contains(&point))
-}
-
-/// Copied from alacritty/src/display/hint.rs:
-/// Iterate over all visible regex matches.
-pub fn visible_regex_match_iter<'a, T>(
-    term: &'a Term<T>,
-    regex: &'a mut RegexSearch,
-) -> impl Iterator<Item = Match> + 'a {
-    let viewport_start = Line(-(term.grid().display_offset() as i32));
-    let viewport_end = viewport_start + term.bottommost_line();
-    let mut start = term.line_search_left(AlacPoint::new(viewport_start, Column(0)));
-    let mut end = term.line_search_right(AlacPoint::new(viewport_end, Column(0)));
-    start.line = start.line.max(viewport_start - MAX_SEARCH_LINES);
-    end.line = end.line.min(viewport_end + MAX_SEARCH_LINES);
-
-    RegexIter::new(start, end, AlacDirection::Right, term, regex)
-        .skip_while(move |rm| rm.end().line < viewport_start)
-        .take_while(move |rm| rm.start().line <= viewport_end)
-}
-
 fn make_selection(range: &RangeInclusive<AlacPoint>) -> Selection {
     let mut selection = Selection::new(SelectionType::Simple, *range.start(), AlacDirection::Left);
     selection.update(*range.end(), AlacDirection::Right);
@@ -2177,8 +2014,7 @@ mod tests {
     use rand::{Rng, distributions::Alphanumeric, rngs::ThreadRng, thread_rng};
 
     use crate::{
-        IndexedCell, TerminalBounds, TerminalContent, content_index_for_mouse,
-        python_extract_path_and_line, rgb_for_index,
+        IndexedCell, TerminalBounds, TerminalContent, content_index_for_mouse, rgb_for_index,
     };
 
     #[test]
@@ -2312,87 +2148,4 @@ mod tests {
             ..Default::default()
         }
     }
-
-    fn re_test(re: &str, hay: &str, expected: Vec<&str>) {
-        let results: Vec<_> = regex::Regex::new(re)
-            .unwrap()
-            .find_iter(hay)
-            .map(|m| m.as_str())
-            .collect();
-        assert_eq!(results, expected);
-    }
-    #[test]
-    fn test_url_regex() {
-        re_test(
-            crate::URL_REGEX,
-            "test http://example.com test mailto:bob@example.com train",
-            vec!["http://example.com", "mailto:bob@example.com"],
-        );
-    }
-    #[test]
-    fn test_word_regex() {
-        re_test(
-            crate::WORD_REGEX,
-            "hello, world! \"What\" is this?",
-            vec!["hello", "world", "What", "is", "this"],
-        );
-    }
-    #[test]
-    fn test_word_regex_with_linenum() {
-        // filename(line) and filename(line,col) as used in MSBuild output
-        // should be considered a single "word", even though comma is
-        // usually a word separator
-        re_test(
-            crate::WORD_REGEX,
-            "a Main.cs(20) b",
-            vec!["a", "Main.cs(20)", "b"],
-        );
-        re_test(
-            crate::WORD_REGEX,
-            "Main.cs(20,5) Error desc",
-            vec!["Main.cs(20,5)", "Error", "desc"],
-        );
-        // filename:line:col is a popular format for unix tools
-        re_test(
-            crate::WORD_REGEX,
-            "a Main.cs:20:5 b",
-            vec!["a", "Main.cs:20:5", "b"],
-        );
-        // Some tools output "filename:line:col:message", which currently isn't
-        // handled correctly, but might be in the future
-        re_test(
-            crate::WORD_REGEX,
-            "Main.cs:20:5:Error desc",
-            vec!["Main.cs:20:5:Error", "desc"],
-        );
-    }
-
-    #[test]
-    fn test_python_file_line_regex() {
-        re_test(
-            crate::PYTHON_FILE_LINE_REGEX,
-            "hay File \"/zed/bad_py.py\", line 8 stack",
-            vec!["File \"/zed/bad_py.py\", line 8"],
-        );
-        re_test(crate::PYTHON_FILE_LINE_REGEX, "unrelated", vec![]);
-    }
-
-    #[test]
-    fn test_python_file_line() {
-        let inputs: Vec<(&str, Option<(&str, u32)>)> = vec![
-            (
-                "File \"/zed/bad_py.py\", line 8",
-                Some(("/zed/bad_py.py", 8u32)),
-            ),
-            ("File \"path/to/zed/bad_py.py\"", None),
-            ("unrelated", None),
-            ("", None),
-        ];
-        let actual = inputs
-            .iter()
-            .map(|input| python_extract_path_and_line(input.0))
-            .collect::<Vec<_>>();
-        let expected = inputs.iter().map(|(_, output)| *output).collect::<Vec<_>>();
-        assert_eq!(actual, expected);
-    }
 }

crates/terminal/src/terminal_hyperlinks.rs 🔗

@@ -0,0 +1,1217 @@
+use alacritty_terminal::{

+    Term,

+    event::EventListener,

+    grid::Dimensions,

+    index::{Boundary, Column, Direction as AlacDirection, Line, Point as AlacPoint},

+    term::search::{Match, RegexIter, RegexSearch},

+};

+use regex::Regex;

+use std::{ops::Index, sync::LazyLock};

+

+const 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{-}\^⟨⟩`]+"#;

+// Optional suffix matches MSBuild diagnostic suffixes for path parsing in PathLikeWithPosition

+// https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-diagnostic-format-for-tasks

+const WORD_REGEX: &str =

+    r#"[\$\+\w.\[\]:/\\@\-~()]+(?:\((?:\d+|\d+,\d+)\))|[\$\+\w.\[\]:/\\@\-~()]+"#;

+

+const PYTHON_FILE_LINE_REGEX: &str = r#"File "(?P<file>[^"]+)", line (?P<line>\d+)"#;

+

+static PYTHON_FILE_LINE_MATCHER: LazyLock<Regex> =

+    LazyLock::new(|| Regex::new(PYTHON_FILE_LINE_REGEX).unwrap());

+

+fn python_extract_path_and_line(input: &str) -> Option<(&str, u32)> {

+    if let Some(captures) = PYTHON_FILE_LINE_MATCHER.captures(input) {

+        let path_part = captures.name("file")?.as_str();

+

+        let line_number: u32 = captures.name("line")?.as_str().parse().ok()?;

+        return Some((path_part, line_number));

+    }

+    None

+}

+

+pub(super) struct RegexSearches {

+    url_regex: RegexSearch,

+    word_regex: RegexSearch,

+    python_file_line_regex: RegexSearch,

+}

+

+impl RegexSearches {

+    pub(super) fn new() -> Self {

+        Self {

+            url_regex: RegexSearch::new(URL_REGEX).unwrap(),

+            word_regex: RegexSearch::new(WORD_REGEX).unwrap(),

+            python_file_line_regex: RegexSearch::new(PYTHON_FILE_LINE_REGEX).unwrap(),

+        }

+    }

+}

+

+pub(super) fn find_from_grid_point<T: EventListener>(

+    term: &Term<T>,

+    point: AlacPoint,

+    regex_searches: &mut RegexSearches,

+) -> Option<(String, bool, Match)> {

+    let grid = term.grid();

+    let link = grid.index(point).hyperlink();

+    let found_word = if link.is_some() {

+        let mut min_index = point;

+        loop {

+            let new_min_index = min_index.sub(term, Boundary::Cursor, 1);

+            if new_min_index == min_index || grid.index(new_min_index).hyperlink() != link {

+                break;

+            } else {

+                min_index = new_min_index

+            }

+        }

+

+        let mut max_index = point;

+        loop {

+            let new_max_index = max_index.add(term, Boundary::Cursor, 1);

+            if new_max_index == max_index || grid.index(new_max_index).hyperlink() != link {

+                break;

+            } else {

+                max_index = new_max_index

+            }

+        }

+

+        let url = link.unwrap().uri().to_owned();

+        let url_match = min_index..=max_index;

+

+        Some((url, true, url_match))

+    } else if let Some(url_match) = regex_match_at(term, point, &mut regex_searches.url_regex) {

+        let url = term.bounds_to_string(*url_match.start(), *url_match.end());

+        Some((url, true, url_match))

+    } else if let Some(python_match) =

+        regex_match_at(term, point, &mut regex_searches.python_file_line_regex)

+    {

+        let matching_line = term.bounds_to_string(*python_match.start(), *python_match.end());

+        python_extract_path_and_line(&matching_line).map(|(file_path, line_number)| {

+            (format!("{file_path}:{line_number}"), false, python_match)

+        })

+    } else if let Some(word_match) = regex_match_at(term, point, &mut regex_searches.word_regex) {

+        let file_path = term.bounds_to_string(*word_match.start(), *word_match.end());

+

+        let (sanitized_match, sanitized_word) = 'sanitize: {

+            let mut word_match = word_match;

+            let mut file_path = file_path;

+

+            if is_path_surrounded_by_common_symbols(&file_path) {

+                word_match = Match::new(

+                    word_match.start().add(term, Boundary::Grid, 1),

+                    word_match.end().sub(term, Boundary::Grid, 1),

+                );

+                file_path = file_path[1..file_path.len() - 1].to_owned();

+            }

+

+            while file_path.ends_with(':') {

+                file_path.pop();

+                word_match = Match::new(

+                    *word_match.start(),

+                    word_match.end().sub(term, Boundary::Grid, 1),

+                );

+            }

+            let mut colon_count = 0;

+            for c in file_path.chars() {

+                if c == ':' {

+                    colon_count += 1;

+                }

+            }

+            // strip trailing comment after colon in case of

+            // file/at/path.rs:row:column:description or error message

+            // so that the file path is `file/at/path.rs:row:column`

+            if colon_count > 2 {

+                let last_index = file_path.rfind(':').unwrap();

+                let prev_is_digit = last_index > 0

+                    && file_path

+                        .chars()

+                        .nth(last_index - 1)

+                        .map_or(false, |c| c.is_ascii_digit());

+                let next_is_digit = last_index < file_path.len() - 1

+                    && file_path

+                        .chars()

+                        .nth(last_index + 1)

+                        .map_or(true, |c| c.is_ascii_digit());

+                if prev_is_digit && !next_is_digit {

+                    let stripped_len = file_path.len() - last_index;

+                    word_match = Match::new(

+                        *word_match.start(),

+                        word_match.end().sub(term, Boundary::Grid, stripped_len),

+                    );

+                    file_path = file_path[0..last_index].to_owned();

+                }

+            }

+

+            break 'sanitize (word_match, file_path);

+        };

+

+        Some((sanitized_word, false, sanitized_match))

+    } else {

+        None

+    };

+

+    found_word.map(|(maybe_url_or_path, is_url, word_match)| {

+        if is_url {

+            // Treat "file://" IRIs like file paths to ensure

+            // that line numbers at the end of the path are

+            // handled correctly

+            if let Some(path) = maybe_url_or_path.strip_prefix("file://") {

+                (path.to_string(), false, word_match)

+            } else {

+                (maybe_url_or_path, true, word_match)

+            }

+        } else {

+            (maybe_url_or_path, false, word_match)

+        }

+    })

+}

+

+fn is_path_surrounded_by_common_symbols(path: &str) -> bool {

+    // Avoid detecting `[]` or `()` strings as paths, surrounded by common symbols

+    path.len() > 2

+        // The rest of the brackets and various quotes cannot be matched by the [`WORD_REGEX`] hence not checked for.

+        && (path.starts_with('[') && path.ends_with(']')

+            || path.starts_with('(') && path.ends_with(')'))

+}

+

+/// Based on alacritty/src/display/hint.rs > regex_match_at

+/// Retrieve the match, if the specified point is inside the content matching the regex.

+fn regex_match_at<T>(term: &Term<T>, point: AlacPoint, regex: &mut RegexSearch) -> Option<Match> {

+    visible_regex_match_iter(term, regex).find(|rm| rm.contains(&point))

+}

+

+/// Copied from alacritty/src/display/hint.rs:

+/// Iterate over all visible regex matches.

+fn visible_regex_match_iter<'a, T>(

+    term: &'a Term<T>,

+    regex: &'a mut RegexSearch,

+) -> impl Iterator<Item = Match> + 'a {

+    const MAX_SEARCH_LINES: usize = 100;

+

+    let viewport_start = Line(-(term.grid().display_offset() as i32));

+    let viewport_end = viewport_start + term.bottommost_line();

+    let mut start = term.line_search_left(AlacPoint::new(viewport_start, Column(0)));

+    let mut end = term.line_search_right(AlacPoint::new(viewport_end, Column(0)));

+    start.line = start.line.max(viewport_start - MAX_SEARCH_LINES);

+    end.line = end.line.min(viewport_end + MAX_SEARCH_LINES);

+

+    RegexIter::new(start, end, AlacDirection::Right, term, regex)

+        .skip_while(move |rm| rm.end().line < viewport_start)

+        .take_while(move |rm| rm.start().line <= viewport_end)

+}

+

+#[cfg(test)]

+mod tests {

+    use super::*;

+    use alacritty_terminal::{

+        event::VoidListener,

+        index::{Boundary, Point as AlacPoint},

+        term::{Config, cell::Flags, test::TermSize},

+        vte::ansi::Handler,

+    };

+    use std::{cell::RefCell, ops::RangeInclusive, path::PathBuf};

+    use url::Url;

+    use util::paths::PathWithPosition;

+

+    fn re_test(re: &str, hay: &str, expected: Vec<&str>) {

+        let results: Vec<_> = regex::Regex::new(re)

+            .unwrap()

+            .find_iter(hay)

+            .map(|m| m.as_str())

+            .collect();

+        assert_eq!(results, expected);

+    }

+

+    #[test]

+    fn test_url_regex() {

+        re_test(

+            URL_REGEX,

+            "test http://example.com test mailto:bob@example.com train",

+            vec!["http://example.com", "mailto:bob@example.com"],

+        );

+    }

+

+    #[test]

+    fn test_word_regex() {

+        re_test(

+            WORD_REGEX,

+            "hello, world! \"What\" is this?",

+            vec!["hello", "world", "What", "is", "this"],

+        );

+    }

+

+    #[test]

+    fn test_word_regex_with_linenum() {

+        // filename(line) and filename(line,col) as used in MSBuild output

+        // should be considered a single "word", even though comma is

+        // usually a word separator

+        re_test(WORD_REGEX, "a Main.cs(20) b", vec!["a", "Main.cs(20)", "b"]);

+        re_test(

+            WORD_REGEX,

+            "Main.cs(20,5) Error desc",

+            vec!["Main.cs(20,5)", "Error", "desc"],

+        );

+        // filename:line:col is a popular format for unix tools

+        re_test(

+            WORD_REGEX,

+            "a Main.cs:20:5 b",

+            vec!["a", "Main.cs:20:5", "b"],

+        );

+        // Some tools output "filename:line:col:message", which currently isn't

+        // handled correctly, but might be in the future

+        re_test(

+            WORD_REGEX,

+            "Main.cs:20:5:Error desc",

+            vec!["Main.cs:20:5:Error", "desc"],

+        );

+    }

+

+    #[test]

+    fn test_python_file_line_regex() {

+        re_test(

+            PYTHON_FILE_LINE_REGEX,

+            "hay File \"/zed/bad_py.py\", line 8 stack",

+            vec!["File \"/zed/bad_py.py\", line 8"],

+        );

+        re_test(PYTHON_FILE_LINE_REGEX, "unrelated", vec![]);

+    }

+

+    #[test]

+    fn test_python_file_line() {

+        let inputs: Vec<(&str, Option<(&str, u32)>)> = vec![

+            (

+                "File \"/zed/bad_py.py\", line 8",

+                Some(("/zed/bad_py.py", 8u32)),

+            ),

+            ("File \"path/to/zed/bad_py.py\"", None),

+            ("unrelated", None),

+            ("", None),

+        ];

+        let actual = inputs

+            .iter()

+            .map(|input| python_extract_path_and_line(input.0))

+            .collect::<Vec<_>>();

+        let expected = inputs.iter().map(|(_, output)| *output).collect::<Vec<_>>();

+        assert_eq!(actual, expected);

+    }

+

+    // We use custom columns in many tests to workaround this issue by ensuring a wrapped

+    // line never ends on a wide char:

+    //

+    // <https://github.com/alacritty/alacritty/issues/8586>

+    //

+    // This issue was recently fixed, as soon as we update to a version containing the fix we

+    // can remove all the custom columns from these tests.

+    //

+    macro_rules! test_hyperlink {

+        ($($lines:expr),+; $hyperlink_kind:ident) => { {

+            use crate::terminal_hyperlinks::tests::line_cells_count;

+            use std::cmp;

+

+            let test_lines = vec![$($lines),+];

+            let (total_cells, longest_line_cells) =

+                test_lines.iter().copied()

+                    .map(line_cells_count)

+                    .fold((0, 0), |state, cells| (state.0 + cells, cmp::max(state.1, cells)));

+

+            test_hyperlink!(

+                // Alacritty has issues with 2 columns, use 3 as the minimum for now.

+                [3, longest_line_cells / 2, longest_line_cells + 1];

+                total_cells;

+                test_lines.iter().copied();

+                $hyperlink_kind

+            )

+        } };

+

+        ($($columns:literal),+; $($lines:expr),+; $hyperlink_kind:ident) => { {

+            use crate::terminal_hyperlinks::tests::line_cells_count;

+

+            let test_lines = vec![$($lines),+];

+            let total_cells = test_lines.iter().copied().map(line_cells_count).sum();

+

+            test_hyperlink!(

+                [ $($columns),+ ]; total_cells; test_lines.iter().copied(); $hyperlink_kind

+            )

+        } };

+

+        ([ $($columns:expr),+ ]; $total_cells:expr; $lines:expr; $hyperlink_kind:ident) => { {

+            use crate::terminal_hyperlinks::tests::{ test_hyperlink, HyperlinkKind };

+

+            let source_location = format!("{}:{}", std::file!(), std::line!());

+            for columns in vec![ $($columns),+] {

+                test_hyperlink(columns, $total_cells, $lines, HyperlinkKind::$hyperlink_kind,

+                    &source_location);

+            }

+        } };

+    }

+

+    mod path {

+        /// 👉 := **hovered** on following char

+        ///

+        /// 👈 := **hovered** on wide char spacer of previous full width char

+        ///

+        /// **`‹›`** := expected **hyperlink** match

+        ///

+        /// **`«»`** := expected **path**, **row**, and **column** capture groups

+        ///

+        /// [**`c₀, c₁, …, cₙ;`**]ₒₚₜ := use specified terminal widths of `c₀, c₁, …, cₙ` **columns**

+        /// (defaults to `3, longest_line_cells / 2, longest_line_cells + 1;`)

+        ///

+        macro_rules! test_path {

+            ($($lines:literal),+) => { test_hyperlink!($($lines),+; Path) };

+            ($($columns:literal),+; $($lines:literal),+) => {

+                test_hyperlink!($($columns),+; $($lines),+; Path)

+            };

+        }

+

+        #[test]

+        fn simple() {

+            // Rust paths

+            // Just the path

+            test_path!("‹«/👉test/cool.rs»›");

+            test_path!("‹«/test/cool👉.rs»›");

+

+            // path and line

+            test_path!("‹«/👉test/cool.rs»:«4»›");

+            test_path!("‹«/test/cool.rs»👉:«4»›");

+            test_path!("‹«/test/cool.rs»:«👉4»›");

+            test_path!("‹«/👉test/cool.rs»(«4»)›");

+            test_path!("‹«/test/cool.rs»👉(«4»)›");

+            test_path!("‹«/test/cool.rs»(«👉4»)›");

+            test_path!("‹«/test/cool.rs»(«4»👉)›");

+

+            // path, line, and column

+            test_path!("‹«/👉test/cool.rs»:«4»:«2»›");

+            test_path!("‹«/test/cool.rs»:«4»:«👉2»›");

+            test_path!("‹«/👉test/cool.rs»(«4»,«2»)›");

+            test_path!("‹«/test/cool.rs»(«4»👉,«2»)›");

+

+            // path, line, column, and ':' suffix

+            test_path!("‹«/👉test/cool.rs»:«4»:«2»›:");

+            test_path!("‹«/test/cool.rs»:«4»:«👉2»›:");

+            test_path!("‹«/👉test/cool.rs»(«4»,«2»)›:");

+            test_path!("‹«/test/cool.rs»(«4»,«2»👉)›:");

+

+            // path, line, column, and description

+            test_path!("‹«/test/cool.rs»:«4»:«2»›👉:Error!");

+            test_path!("‹«/test/cool.rs»:«4»:«2»›:👉Error!");

+            test_path!("‹«/test/co👉ol.rs»(«4»,«2»)›:Error!");

+

+            // Cargo output

+            test_path!("    Compiling Cool 👉(‹«/test/Cool»›)");

+            test_path!("    Compiling Cool (‹«/👉test/Cool»›)");

+            test_path!("    Compiling Cool (‹«/test/Cool»›👉)");

+

+            // Python

+            test_path!("‹«awe👉some.py»›");

+

+            test_path!("    ‹F👉ile \"«/awesome.py»\", line «42»›: Wat?");

+            test_path!("    ‹File \"«/awe👉some.py»\", line «42»›: Wat?");

+            test_path!("    ‹File \"«/awesome.py»👉\", line «42»›: Wat?");

+            test_path!("    ‹File \"«/awesome.py»\", line «4👉2»›: Wat?");

+        }

+

+        #[test]

+        fn colons_galore() {

+            test_path!("‹«/test/co👉ol.rs»:«4»›");

+            test_path!("‹«/test/co👉ol.rs»:«4»›:");

+            test_path!("‹«/test/co👉ol.rs»:«4»:«2»›");

+            test_path!("‹«/test/co👉ol.rs»:«4»:«2»›:");

+            test_path!("‹«/test/co👉ol.rs»(«1»)›");

+            test_path!("‹«/test/co👉ol.rs»(«1»)›:");

+            test_path!("‹«/test/co👉ol.rs»(«1»,«618»)›");

+            test_path!("‹«/test/co👉ol.rs»(«1»,«618»)›:");

+            test_path!("‹«/test/co👉ol.rs»::«42»›");

+            test_path!("‹«/test/co👉ol.rs»::«42»›:");

+            test_path!("‹«/test/co👉ol.rs:4:2»(«1»,«618»)›");

+            test_path!("‹«/test/co👉ol.rs»(«1»,«618»)›::");

+        }

+

+        #[test]

+        fn word_wide_chars() {

+            // Rust paths

+            test_path!(4, 6, 12; "‹«/👉例/cool.rs»›");

+            test_path!(4, 6, 12; "‹«/例👈/cool.rs»›");

+            test_path!(4, 8, 16; "‹«/例/cool.rs»:«👉4»›");

+            test_path!(4, 8, 16; "‹«/例/cool.rs»:«4»:«👉2»›");

+

+            // Cargo output

+            test_path!(4, 27, 30; "    Compiling Cool (‹«/👉例/Cool»›)");

+            test_path!(4, 27, 30; "    Compiling Cool (‹«/例👈/Cool»›)");

+

+            // Python

+            test_path!(4, 11; "‹«👉例wesome.py»›");

+            test_path!(4, 11; "‹«例👈wesome.py»›");

+            test_path!(6, 17, 40; "    ‹File \"«/👉例wesome.py»\", line «42»›: Wat?");

+            test_path!(6, 17, 40; "    ‹File \"«/例👈wesome.py»\", line «42»›: Wat?");

+        }

+

+        #[test]

+        fn non_word_wide_chars() {

+            // Mojo diagnostic message

+            test_path!(4, 18, 38; "    ‹File \"«/awe👉some.🔥»\", line «42»›: Wat?");

+            test_path!(4, 18, 38; "    ‹File \"«/awesome👉.🔥»\", line «42»›: Wat?");

+            test_path!(4, 18, 38; "    ‹File \"«/awesome.👉🔥»\", line «42»›: Wat?");

+            test_path!(4, 18, 38; "    ‹File \"«/awesome.🔥👈»\", line «42»›: Wat?");

+        }

+

+        /// These likely rise to the level of being worth fixing.

+        mod issues {

+            #[test]

+            #[cfg_attr(not(target_os = "windows"), should_panic(expected = "Path = «例»"))]

+            #[cfg_attr(target_os = "windows", should_panic(expected = r#"Path = «C:\\例»"#))]

+            // <https://github.com/alacritty/alacritty/issues/8586>

+            fn issue_alacritty_8586() {

+                // Rust paths

+                test_path!("‹«/👉例/cool.rs»›");

+                test_path!("‹«/例👈/cool.rs»›");

+                test_path!("‹«/例/cool.rs»:«👉4»›");

+                test_path!("‹«/例/cool.rs»:«4»:«👉2»›");

+

+                // Cargo output

+                test_path!("    Compiling Cool (‹«/👉例/Cool»›)");

+                test_path!("    Compiling Cool (‹«/例👈/Cool»›)");

+

+                // Python

+                test_path!("‹«👉例wesome.py»›");

+                test_path!("‹«例👈wesome.py»›");

+                test_path!("    ‹File \"«/👉例wesome.py»\", line «42»›: Wat?");

+                test_path!("    ‹File \"«/例👈wesome.py»\", line «42»›: Wat?");

+            }

+

+            #[test]

+            #[should_panic(expected = "No hyperlink found")]

+            // <https://github.com/zed-industries/zed/issues/12338>

+            fn issue_12338() {

+                // Issue #12338

+                test_path!(".rw-r--r--     0     staff 05-27 14:03 ‹«test👉、2.txt»›");

+                test_path!(".rw-r--r--     0     staff 05-27 14:03 ‹«test、👈2.txt»›");

+                test_path!(".rw-r--r--     0     staff 05-27 14:03 ‹«test👉。3.txt»›");

+                test_path!(".rw-r--r--     0     staff 05-27 14:03 ‹«test。👈3.txt»›");

+

+                // Rust paths

+                test_path!("‹«/👉🏃/🦀.rs»›");

+                test_path!("‹«/🏃👈/🦀.rs»›");

+                test_path!("‹«/🏃/👉🦀.rs»:«4»›");

+                test_path!("‹«/🏃/🦀👈.rs»:«4»:«2»›");

+

+                // Cargo output

+                test_path!("    Compiling Cool (‹«/👉🏃/Cool»›)");

+                test_path!("    Compiling Cool (‹«/🏃👈/Cool»›)");

+

+                // Python

+                test_path!("‹«👉🏃wesome.py»›");

+                test_path!("‹«🏃👈wesome.py»›");

+                test_path!("    ‹File \"«/👉🏃wesome.py»\", line «42»›: Wat?");

+                test_path!("    ‹File \"«/🏃👈wesome.py»\", line «42»›: Wat?");

+

+                // Mojo

+                test_path!("‹«/awe👉some.🔥»› is some good Mojo!");

+                test_path!("‹«/awesome👉.🔥»› is some good Mojo!");

+                test_path!("‹«/awesome.👉🔥»› is some good Mojo!");

+                test_path!("‹«/awesome.🔥👈»› is some good Mojo!");

+                test_path!("    ‹File \"«/👉🏃wesome.🔥»\", line «42»›: Wat?");

+                test_path!("    ‹File \"«/🏃👈wesome.🔥»\", line «42»›: Wat?");

+            }

+

+            #[test]

+            #[cfg_attr(

+                not(target_os = "windows"),

+                should_panic(

+                    expected = "Path = «test/controllers/template_items_controller_test.rb», line = 20, at grid cells (0, 0)..=(17, 1)"

+                )

+            )]

+            #[cfg_attr(

+                target_os = "windows",

+                should_panic(

+                    expected = r#"Path = «test\\controllers\\template_items_controller_test.rb», line = 20, at grid cells (0, 0)..=(17, 1)"#

+                )

+            )]

+            // <https://github.com/zed-industries/zed/issues/28194>

+            //

+            // #28194 was closed, but the link includes the description part (":in" here), which

+            // seems wrong...

+            fn issue_28194() {

+                test_path!(

+                    "‹«test/c👉ontrollers/template_items_controller_test.rb»:«20»›:in 'block (2 levels) in <class:TemplateItemsControllerTest>'"

+                );

+                test_path!(

+                    "‹«test/controllers/template_items_controller_test.rb»:«19»›:i👉n 'block in <class:TemplateItemsControllerTest>'"

+                );

+            }

+        }

+

+        /// Minor issues arguably not important enough to fix/workaround...

+        mod nits {

+            #[test]

+            #[cfg_attr(

+                not(target_os = "windows"),

+                should_panic(expected = "Path = «/test/cool.rs(4»")

+            )]

+            #[cfg_attr(

+                target_os = "windows",

+                should_panic(expected = r#"Path = «C:\\test\\cool.rs(4»"#)

+            )]

+            fn alacritty_bugs_with_two_columns() {

+                test_path!(2; "‹«/👉test/cool.rs»(«4»)›");

+                test_path!(2; "‹«/test/cool.rs»(«👉4»)›");

+                test_path!(2; "‹«/test/cool.rs»(«4»,«👉2»)›");

+

+                // Python

+                test_path!(2; "‹«awe👉some.py»›");

+            }

+

+            #[test]

+            #[cfg_attr(

+                not(target_os = "windows"),

+                should_panic(

+                    expected = "Path = «/test/cool.rs», line = 1, at grid cells (0, 0)..=(9, 0)"

+                )

+            )]

+            #[cfg_attr(

+                target_os = "windows",

+                should_panic(

+                    expected = r#"Path = «C:\\test\\cool.rs», line = 1, at grid cells (0, 0)..=(9, 2)"#

+                )

+            )]

+            fn invalid_row_column_should_be_part_of_path() {

+                test_path!("‹«/👉test/cool.rs:1:618033988749»›");

+                test_path!("‹«/👉test/cool.rs(1,618033988749)»›");

+            }

+

+            #[test]

+            #[should_panic(expected = "Path = «»")]

+            fn colon_suffix_succeeds_in_finding_an_empty_maybe_path() {

+                test_path!("‹«/test/cool.rs»:«4»:«2»›👉:", "What is this?");

+                test_path!("‹«/test/cool.rs»(«4»,«2»)›👉:", "What is this?");

+            }

+

+            #[test]

+            #[cfg_attr(

+                not(target_os = "windows"),

+                should_panic(expected = "Path = «/test/cool.rs»")

+            )]

+            #[cfg_attr(

+                target_os = "windows",

+                should_panic(expected = r#"Path = «C:\\test\\cool.rs»"#)

+            )]

+            fn many_trailing_colons_should_be_parsed_as_part_of_the_path() {

+                test_path!("‹«/test/cool.rs:::👉:»›");

+                test_path!("‹«/te:st/👉co:ol.r:s:4:2::::::»›");

+            }

+        }

+

+        #[cfg(target_os = "windows")]

+        mod windows {

+            // Lots of fun to be had with long file paths (verbatim) and UNC paths on Windows.

+            // See <https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation>

+            // See <https://users.rust-lang.org/t/understanding-windows-paths/58583>

+            // See <https://github.com/rust-lang/cargo/issues/13919>

+

+            #[test]

+            fn unc() {

+                test_path!(r#"‹«\\server\share\👉test\cool.rs»›"#);

+                test_path!(r#"‹«\\server\share\test\cool👉.rs»›"#);

+            }

+

+            mod issues {

+                #[test]

+                #[should_panic(

+                    expected = r#"Path = «C:\\test\\cool.rs», at grid cells (0, 0)..=(6, 0)"#

+                )]

+                fn issue_verbatim() {

+                    test_path!(r#"‹«\\?\C:\👉test\cool.rs»›"#);

+                    test_path!(r#"‹«\\?\C:\test\cool👉.rs»›"#);

+                }

+

+                #[test]

+                #[should_panic(

+                    expected = r#"Path = «\\\\server\\share\\test\\cool.rs», at grid cells (0, 0)..=(10, 2)"#

+                )]

+                fn issue_verbatim_unc() {

+                    test_path!(r#"‹«\\?\UNC\server\share\👉test\cool.rs»›"#);

+                    test_path!(r#"‹«\\?\UNC\server\share\test\cool👉.rs»›"#);

+                }

+            }

+        }

+    }

+

+    mod file_iri {

+        // File IRIs have a ton of use cases, most of which we currently do not support. A few of

+        // those cases are documented here as tests which are expected to fail.

+        // See https://en.wikipedia.org/wiki/File_URI_scheme

+

+        /// [**`c₀, c₁, …, cₙ;`**]ₒₚₜ := use specified terminal widths of `c₀, c₁, …, cₙ` **columns**

+        /// (defaults to `3, longest_line_cells / 2, longest_line_cells + 1;`)

+        ///

+        macro_rules! test_file_iri {

+            ($file_iri:literal) => { { test_hyperlink!(concat!("‹«👉", $file_iri, "»›"); FileIri) } };

+            ($($columns:literal),+; $file_iri:literal) => { {

+                test_hyperlink!($($columns),+; concat!("‹«👉", $file_iri, "»›"); FileIri)

+            } };

+        }

+

+        #[cfg(not(target_os = "windows"))]

+        #[test]

+        fn absolute_file_iri() {

+            test_file_iri!("file:///test/cool/index.rs");

+            test_file_iri!("file:///test/cool/");

+        }

+

+        mod issues {

+            #[cfg(not(target_os = "windows"))]

+            #[test]

+            #[should_panic(expected = "Path = «/test/Ῥόδος/», at grid cells (0, 0)..=(15, 1)")]

+            fn issue_file_iri_with_percent_encoded_characters() {

+                // Non-space characters

+                // file:///test/Ῥόδος/

+                test_file_iri!("file:///test/%E1%BF%AC%CF%8C%CE%B4%CE%BF%CF%82/"); // URI

+

+                // Spaces

+                test_file_iri!("file:///te%20st/co%20ol/index.rs");

+                test_file_iri!("file:///te%20st/co%20ol/");

+            }

+        }

+

+        #[cfg(target_os = "windows")]

+        mod windows {

+            mod issues {

+                // The test uses Url::to_file_path(), but it seems that the Url crate doesn't

+                // support relative file IRIs.

+                #[test]

+                #[should_panic(

+                    expected = r#"Failed to interpret file IRI `file:/test/cool/index.rs` as a path"#

+                )]

+                fn issue_relative_file_iri() {

+                    test_file_iri!("file:/test/cool/index.rs");

+                    test_file_iri!("file:/test/cool/");

+                }

+

+                // See https://en.wikipedia.org/wiki/File_URI_scheme

+                #[test]

+                #[should_panic(

+                    expected = r#"Path = «C:\\test\\cool\\index.rs», at grid cells (0, 0)..=(9, 1)"#

+                )]

+                fn issue_absolute_file_iri() {

+                    test_file_iri!("file:///C:/test/cool/index.rs");

+                    test_file_iri!("file:///C:/test/cool/");

+                }

+

+                #[test]

+                #[should_panic(

+                    expected = r#"Path = «C:\\test\\Ῥόδος\\», at grid cells (0, 0)..=(16, 1)"#

+                )]

+                fn issue_file_iri_with_percent_encoded_characters() {

+                    // Non-space characters

+                    // file:///test/Ῥόδος/

+                    test_file_iri!("file:///C:/test/%E1%BF%AC%CF%8C%CE%B4%CE%BF%CF%82/"); // URI

+

+                    // Spaces

+                    test_file_iri!("file:///C:/te%20st/co%20ol/index.rs");

+                    test_file_iri!("file:///C:/te%20st/co%20ol/");

+                }

+            }

+        }

+    }

+

+    mod iri {

+        /// [**`c₀, c₁, …, cₙ;`**]ₒₚₜ := use specified terminal widths of `c₀, c₁, …, cₙ` **columns**

+        /// (defaults to `3, longest_line_cells / 2, longest_line_cells + 1;`)

+        ///

+        macro_rules! test_iri {

+            ($iri:literal) => { { test_hyperlink!(concat!("‹«👉", $iri, "»›"); Iri) } };

+            ($($columns:literal),+; $iri:literal) => { {

+                test_hyperlink!($($columns),+; concat!("‹«👉", $iri, "»›"); Iri)

+            } };

+        }

+

+        #[test]

+        fn simple() {

+            // In the order they appear in URL_REGEX, except 'file://' which is treated as a path

+            test_iri!("ipfs://test/cool.ipfs");

+            test_iri!("ipns://test/cool.ipns");

+            test_iri!("magnet://test/cool.git");

+            test_iri!("mailto:someone@somewhere.here");

+            test_iri!("gemini://somewhere.here");

+            test_iri!("gopher://somewhere.here");

+            test_iri!("http://test/cool/index.html");

+            test_iri!("http://10.10.10.10:1111/cool.html");

+            test_iri!("http://test/cool/index.html?amazing=1");

+            test_iri!("http://test/cool/index.html#right%20here");

+            test_iri!("http://test/cool/index.html?amazing=1#right%20here");

+            test_iri!("https://test/cool/index.html");

+            test_iri!("https://10.10.10.10:1111/cool.html");

+            test_iri!("https://test/cool/index.html?amazing=1");

+            test_iri!("https://test/cool/index.html#right%20here");

+            test_iri!("https://test/cool/index.html?amazing=1#right%20here");

+            test_iri!("news://test/cool.news");

+            test_iri!("git://test/cool.git");

+            test_iri!("ssh://user@somewhere.over.here:12345/test/cool.git");

+            test_iri!("ftp://test/cool.ftp");

+        }

+

+        #[test]

+        fn wide_chars() {

+            // In the order they appear in URL_REGEX, except 'file://' which is treated as a path

+            test_iri!(4, 20; "ipfs://例🏃🦀/cool.ipfs");

+            test_iri!(4, 20; "ipns://例🏃🦀/cool.ipns");

+            test_iri!(6, 20; "magnet://例🏃🦀/cool.git");

+            test_iri!(4, 20; "mailto:someone@somewhere.here");

+            test_iri!(4, 20; "gemini://somewhere.here");

+            test_iri!(4, 20; "gopher://somewhere.here");

+            test_iri!(4, 20; "http://例🏃🦀/cool/index.html");

+            test_iri!(4, 20; "http://10.10.10.10:1111/cool.html");

+            test_iri!(4, 20; "http://例🏃🦀/cool/index.html?amazing=1");

+            test_iri!(4, 20; "http://例🏃🦀/cool/index.html#right%20here");

+            test_iri!(4, 20; "http://例🏃🦀/cool/index.html?amazing=1#right%20here");

+            test_iri!(4, 20; "https://例🏃🦀/cool/index.html");

+            test_iri!(4, 20; "https://10.10.10.10:1111/cool.html");

+            test_iri!(4, 20; "https://例🏃🦀/cool/index.html?amazing=1");

+            test_iri!(4, 20; "https://例🏃🦀/cool/index.html#right%20here");

+            test_iri!(4, 20; "https://例🏃🦀/cool/index.html?amazing=1#right%20here");

+            test_iri!(4, 20; "news://例🏃🦀/cool.news");

+            test_iri!(5, 20; "git://例/cool.git");

+            test_iri!(5, 20; "ssh://user@somewhere.over.here:12345/例🏃🦀/cool.git");

+            test_iri!(7, 20; "ftp://例🏃🦀/cool.ftp");

+        }

+

+        // There are likely more tests needed for IRI vs URI

+        #[test]

+        fn iris() {

+            // These refer to the same location, see example here:

+            // <https://en.wikipedia.org/wiki/Internationalized_Resource_Identifier#Compatibility>

+            test_iri!("https://en.wiktionary.org/wiki/Ῥόδος"); // IRI

+            test_iri!("https://en.wiktionary.org/wiki/%E1%BF%AC%CF%8C%CE%B4%CE%BF%CF%82"); // URI

+        }

+

+        #[test]

+        #[should_panic(expected = "Expected a path, but was a iri")]

+        fn file_is_a_path() {

+            test_iri!("file://test/cool/index.rs");

+        }

+    }

+

+    #[derive(Debug, PartialEq)]

+    enum HyperlinkKind {

+        FileIri,

+        Iri,

+        Path,

+    }

+

+    struct ExpectedHyperlink {

+        hovered_grid_point: AlacPoint,

+        hovered_char: char,

+        hyperlink_kind: HyperlinkKind,

+        iri_or_path: String,

+        row: Option<u32>,

+        column: Option<u32>,

+        hyperlink_match: RangeInclusive<AlacPoint>,

+    }

+

+    /// Converts to Windows style paths on Windows, like path!(), but at runtime for improved test

+    /// readability.

+    fn build_term_from_test_lines<'a>(

+        hyperlink_kind: HyperlinkKind,

+        term_size: TermSize,

+        test_lines: impl Iterator<Item = &'a str>,

+    ) -> (Term<VoidListener>, ExpectedHyperlink) {

+        #[derive(Default, Eq, PartialEq)]

+        enum HoveredState {

+            #[default]

+            HoveredScan,

+            HoveredNextChar,

+            Done,

+        }

+

+        #[derive(Default, Eq, PartialEq)]

+        enum MatchState {

+            #[default]

+            MatchScan,

+            MatchNextChar,

+            Match(AlacPoint),

+            Done,

+        }

+

+        #[derive(Default, Eq, PartialEq)]

+        enum CapturesState {

+            #[default]

+            PathScan,

+            PathNextChar,

+            Path(AlacPoint),

+            RowScan,

+            Row(String),

+            ColumnScan,

+            Column(String),

+            Done,

+        }

+

+        fn prev_input_point_from_term(term: &Term<VoidListener>) -> AlacPoint {

+            let grid = term.grid();

+            let cursor = &grid.cursor;

+            let mut point = cursor.point;

+

+            if !cursor.input_needs_wrap {

+                point.column -= 1;

+            }

+

+            if grid.index(point).flags.contains(Flags::WIDE_CHAR_SPACER) {

+                point.column -= 1;

+            }

+

+            point

+        }

+

+        let mut hovered_grid_point: Option<AlacPoint> = None;

+        let mut hyperlink_match = AlacPoint::default()..=AlacPoint::default();

+        let mut iri_or_path = String::default();

+        let mut row = None;

+        let mut column = None;

+        let mut prev_input_point = AlacPoint::default();

+        let mut hovered_state = HoveredState::default();

+        let mut match_state = MatchState::default();

+        let mut captures_state = CapturesState::default();

+        let mut term = Term::new(Config::default(), &term_size, VoidListener);

+

+        for text in test_lines {

+            let chars: Box<dyn Iterator<Item = char>> =

+                if cfg!(windows) && hyperlink_kind == HyperlinkKind::Path {

+                    Box::new(text.chars().map(|c| if c == '/' { '\\' } else { c })) as _

+                } else {

+                    Box::new(text.chars()) as _

+                };

+            let mut chars = chars.peekable();

+            while let Some(c) = chars.next() {

+                match c {

+                    '👉' => {

+                        hovered_state = HoveredState::HoveredNextChar;

+                    }

+                    '👈' => {

+                        hovered_grid_point = Some(prev_input_point.add(&term, Boundary::Grid, 1));

+                    }

+                    '«' | '»' => {

+                        captures_state = match captures_state {

+                            CapturesState::PathScan => CapturesState::PathNextChar,

+                            CapturesState::PathNextChar => {

+                                panic!("Should have been handled by char input")

+                            }

+                            CapturesState::Path(start_point) => {

+                                iri_or_path = term.bounds_to_string(start_point, prev_input_point);

+                                CapturesState::RowScan

+                            }

+                            CapturesState::RowScan => CapturesState::Row(String::new()),

+                            CapturesState::Row(number) => {

+                                row = Some(number.parse::<u32>().unwrap());

+                                CapturesState::ColumnScan

+                            }

+                            CapturesState::ColumnScan => CapturesState::Column(String::new()),

+                            CapturesState::Column(number) => {

+                                column = Some(number.parse::<u32>().unwrap());

+                                CapturesState::Done

+                            }

+                            CapturesState::Done => {

+                                panic!("Extra '«', '»'")

+                            }

+                        }

+                    }

+                    '‹' | '›' => {

+                        match_state = match match_state {

+                            MatchState::MatchScan => MatchState::MatchNextChar,

+                            MatchState::MatchNextChar => {

+                                panic!("Should have been handled by char input")

+                            }

+                            MatchState::Match(start_point) => {

+                                hyperlink_match = start_point..=prev_input_point;

+                                MatchState::Done

+                            }

+                            MatchState::Done => {

+                                panic!("Extra '‹', '›'")

+                            }

+                        }

+                    }

+                    _ => {

+                        if let CapturesState::Row(number) | CapturesState::Column(number) =

+                            &mut captures_state

+                        {

+                            number.push(c)

+                        }

+

+                        let is_windows_abs_path_start = captures_state

+                            == CapturesState::PathNextChar

+                            && cfg!(windows)

+                            && hyperlink_kind == HyperlinkKind::Path

+                            && c == '\\'

+                            && chars.peek().is_some_and(|c| *c != '\\');

+

+                        if is_windows_abs_path_start {

+                            // Convert Unix abs path start into Windows abs path start so that the

+                            // same test can be used for both OSes.

+                            term.input('C');

+                            prev_input_point = prev_input_point_from_term(&term);

+                            term.input(':');

+                            term.input(c);

+                        } else {

+                            term.input(c);

+                            prev_input_point = prev_input_point_from_term(&term);

+                        }

+

+                        if hovered_state == HoveredState::HoveredNextChar {

+                            hovered_grid_point = Some(prev_input_point);

+                            hovered_state = HoveredState::Done;

+                        }

+                        if captures_state == CapturesState::PathNextChar {

+                            captures_state = CapturesState::Path(prev_input_point);

+                        }

+                        if match_state == MatchState::MatchNextChar {

+                            match_state = MatchState::Match(prev_input_point);

+                        }

+                    }

+                }

+            }

+            term.move_down_and_cr(1);

+        }

+

+        if hyperlink_kind == HyperlinkKind::FileIri {

+            let Ok(url) = Url::parse(&iri_or_path) else {

+                panic!("Failed to parse file IRI `{iri_or_path}`");

+            };

+            let Ok(path) = url.to_file_path() else {

+                panic!("Failed to interpret file IRI `{iri_or_path}` as a path");

+            };

+            iri_or_path = path.to_string_lossy().to_string();

+        }

+

+        if cfg!(windows) {

+            // Handle verbatim and UNC paths for Windows

+            if let Some(stripped) = iri_or_path.strip_prefix(r#"\\?\UNC\"#) {

+                iri_or_path = format!(r#"\\{stripped}"#);

+            } else if let Some(stripped) = iri_or_path.strip_prefix(r#"\\?\"#) {

+                iri_or_path = stripped.to_string();

+            }

+        }

+

+        let hovered_grid_point = hovered_grid_point.expect("Missing hovered point (👉 or 👈)");

+        let hovered_char = term.grid().index(hovered_grid_point).c;

+        (

+            term,

+            ExpectedHyperlink {

+                hovered_grid_point,

+                hovered_char,

+                hyperlink_kind,

+                iri_or_path,

+                row,

+                column,

+                hyperlink_match,

+            },

+        )

+    }

+

+    fn line_cells_count(line: &str) -> usize {

+        // This avoids taking a dependency on the unicode-width crate

+        fn width(c: char) -> usize {

+            match c {

+                // Fullwidth unicode characters used in tests

+                '例' | '🏃' | '🦀' | '🔥' => 2,

+                _ => 1,

+            }

+        }

+        const CONTROL_CHARS: &str = "‹«👉👈»›";

+        line.chars()

+            .filter(|c| !CONTROL_CHARS.contains(*c))

+            .map(width)

+            .sum::<usize>()

+    }

+

+    struct CheckHyperlinkMatch<'a> {

+        term: &'a Term<VoidListener>,

+        expected_hyperlink: &'a ExpectedHyperlink,

+        source_location: &'a str,

+    }

+

+    impl<'a> CheckHyperlinkMatch<'a> {

+        fn new(

+            term: &'a Term<VoidListener>,

+            expected_hyperlink: &'a ExpectedHyperlink,

+            source_location: &'a str,

+        ) -> Self {

+            Self {

+                term,

+                expected_hyperlink,

+                source_location,

+            }

+        }

+

+        fn check_path_with_position_and_match(

+            &self,

+            path_with_position: PathWithPosition,

+            hyperlink_match: &Match,

+        ) {

+            let format_path_with_position_and_match =

+                |path_with_position: &PathWithPosition, hyperlink_match: &Match| {

+                    let mut result =

+                        format!("Path = «{}»", &path_with_position.path.to_string_lossy());

+                    if let Some(row) = path_with_position.row {

+                        result += &format!(", line = {row}");

+                        if let Some(column) = path_with_position.column {

+                            result += &format!(", column = {column}");

+                        }

+                    }

+

+                    result += &format!(

+                        ", at grid cells {}",

+                        Self::format_hyperlink_match(hyperlink_match)

+                    );

+                    result

+                };

+

+            assert_ne!(

+                self.expected_hyperlink.hyperlink_kind,

+                HyperlinkKind::Iri,

+                "\n    at {}\nExpected a path, but was a iri:\n{}",

+                self.source_location,

+                self.format_renderable_content()

+            );

+

+            assert_eq!(

+                format_path_with_position_and_match(

+                    &PathWithPosition {

+                        path: PathBuf::from(self.expected_hyperlink.iri_or_path.clone()),

+                        row: self.expected_hyperlink.row,

+                        column: self.expected_hyperlink.column

+                    },

+                    &self.expected_hyperlink.hyperlink_match

+                ),

+                format_path_with_position_and_match(&path_with_position, hyperlink_match),

+                "\n    at {}:\n{}",

+                self.source_location,

+                self.format_renderable_content()

+            );

+        }

+

+        fn check_iri_and_match(&self, iri: String, hyperlink_match: &Match) {

+            let format_iri_and_match = |iri: &String, hyperlink_match: &Match| {

+                format!(

+                    "Url = «{iri}», at grid cells {}",

+                    Self::format_hyperlink_match(hyperlink_match)

+                )

+            };

+

+            assert_eq!(

+                self.expected_hyperlink.hyperlink_kind,

+                HyperlinkKind::Iri,

+                "\n    at {}\nExpected a iri, but was a path:\n{}",

+                self.source_location,

+                self.format_renderable_content()

+            );

+

+            assert_eq!(

+                format_iri_and_match(

+                    &self.expected_hyperlink.iri_or_path,

+                    &self.expected_hyperlink.hyperlink_match

+                ),

+                format_iri_and_match(&iri, hyperlink_match),

+                "\n    at {}:\n{}",

+                self.source_location,

+                self.format_renderable_content()

+            );

+        }

+

+        fn format_hyperlink_match(hyperlink_match: &Match) -> String {

+            format!(

+                "({}, {})..=({}, {})",

+                hyperlink_match.start().line.0,

+                hyperlink_match.start().column.0,

+                hyperlink_match.end().line.0,

+                hyperlink_match.end().column.0

+            )

+        }

+

+        fn format_renderable_content(&self) -> String {

+            let mut result = format!("\nHovered on '{}'\n", self.expected_hyperlink.hovered_char);

+

+            let mut first_header_row = String::new();

+            let mut second_header_row = String::new();

+            let mut marker_header_row = String::new();

+            for index in 0..self.term.columns() {

+                let remainder = index % 10;

+                first_header_row.push_str(

+                    &(index > 0 && remainder == 0)

+                        .then_some((index / 10).to_string())

+                        .unwrap_or(" ".into()),

+                );

+                second_header_row += &remainder.to_string();

+                if index == self.expected_hyperlink.hovered_grid_point.column.0 {

+                    marker_header_row.push('↓');

+                } else {

+                    marker_header_row.push(' ');

+                }

+            }

+

+            result += &format!("\n      [{}]\n", first_header_row);

+            result += &format!("      [{}]\n", second_header_row);

+            result += &format!("       {}", marker_header_row);

+

+            let spacers: Flags = Flags::LEADING_WIDE_CHAR_SPACER | Flags::WIDE_CHAR_SPACER;

+            for cell in self

+                .term

+                .renderable_content()

+                .display_iter

+                .filter(|cell| !cell.flags.intersects(spacers))

+            {

+                if cell.point.column.0 == 0 {

+                    let prefix =

+                        if cell.point.line == self.expected_hyperlink.hovered_grid_point.line {

+                            '→'

+                        } else {

+                            ' '

+                        };

+                    result += &format!("\n{prefix}[{:>3}] ", cell.point.line.to_string());

+                }

+

+                result.push(cell.c);

+            }

+

+            result

+        }

+    }

+

+    fn test_hyperlink<'a>(

+        columns: usize,

+        total_cells: usize,

+        test_lines: impl Iterator<Item = &'a str>,

+        hyperlink_kind: HyperlinkKind,

+        source_location: &str,

+    ) {

+        thread_local! {

+            static TEST_REGEX_SEARCHES: RefCell<RegexSearches> = RefCell::new(RegexSearches::new());

+        }

+

+        let term_size = TermSize::new(columns, total_cells / columns + 2);

+        let (term, expected_hyperlink) =

+            build_term_from_test_lines(hyperlink_kind, term_size, test_lines);

+        let hyperlink_found = TEST_REGEX_SEARCHES.with(|regex_searches| {

+            find_from_grid_point(

+                &term,

+                expected_hyperlink.hovered_grid_point,

+                &mut regex_searches.borrow_mut(),

+            )

+        });

+        let check_hyperlink_match =

+            CheckHyperlinkMatch::new(&term, &expected_hyperlink, source_location);

+        match hyperlink_found {

+            Some((hyperlink_word, false, hyperlink_match)) => {

+                check_hyperlink_match.check_path_with_position_and_match(

+                    PathWithPosition::parse_str(&hyperlink_word),

+                    &hyperlink_match,

+                );

+            }

+            Some((hyperlink_word, true, hyperlink_match)) => {

+                check_hyperlink_match.check_iri_and_match(hyperlink_word, &hyperlink_match);

+            }

+            _ => {

+                assert!(

+                    false,

+                    "No hyperlink found\n     at {source_location}:\n{}",

+                    check_hyperlink_match.format_renderable_content()

+                )

+            }

+        }

+    }

+}