Files
oak-gpui/crates/terminal/src/terminal_hyperlinks.rs
T
Dave Waggoner e76b485de3 terminal: New settings for path hyperlink regexes (#40305)
Closes:
- #12338
- #40202 

1. Adds two new settings which allow customizing the set of regexes used
to identify path hyperlinks in terminal
1. Fixes path hyperlinks for paths containing unicode emoji and
punctuation, for example, `mojo.🔥`
1. Fixes path hyperlinks for Windows verbatim paths, for example,
`\\?\C:\Over\here.rs`.
1. Improves path hyperlink performance, especially for terminals with a
lot of content
1. Replaces existing custom hard-coded default path hyperlink parsing
logic with a set of customizable default regexes

## New settings

(from default.json)

### terminal.path_hyperlink_regexes

Regexes used to identify paths for hyperlink navigation. Supports
optional named capture
groups `path`, `line`, `column`, and `link`. If none of these are
present, the entire match
is the hyperlink target. If `path` is present, it is the hyperlink
target, along with `line`
and `column` if present. `link` may be used to customize what text in
terminal is part of the
hyperlink. If `link` is not present, the text of the entire match is
used. If `line` and
`column` are not present, the default built-in line and column suffix
processing is used
which parses `line:column` and `(line,column)` variants. The default
value handles Python
diagnostics and common path, line, column syntaxes. This can be extended
or replaced to
handle specific scenarios. For example, to enable support for
hyperlinking paths which
contain spaces in rust output,
```
[
  "\\s+(-->|:::|at) (?<link>(?<path>.+?))(:$|$)",
  "\\s+(Compiling|Checking|Documenting) [^(]+\\((?<link>(?<path>.+))\\)"
],
```
could be used. Processing stops at the first regex with a match, even if
no link is
produced which is the case when the cursor is not over the hyperlinked
text. For best
performance it is recommended to order regexes from most common to least
common. For
readability and documentation, each regex may be an array of strings
which are collected
into one multi-line regex string for use in terminal path hyperlink
detection.

### terminal.path_hyperlink_timeout_ms
Timeout for hover and Cmd-click path hyperlink discovery in
milliseconds. Specifying a
timeout of `0` will disable path hyperlinking in terminal.

## Performance

This PR fixes terminal to only search the hovered line for hyperlinks
and adds a benchmark. Before this fix, hyperlink detection grows
linearly with terminal content, with this fix it is proportional only to
the hovered line. The gains come from replacing
`visible_regex_match_iter`, which searched all visible lines, with code
that only searches the line hovered on (including if the line is
wrapped).

Local benchmark timings (terminal with 500 lines of content):

||main|this PR|Δ|
|-|-|-:|-|
| cargo_hyperlink_benchmark | 1.4 ms | 13 µs | -99.0% |
| rust_hyperlink_benchmark | 1.2 ms | 11 µs | -99.1% |
| ls_hyperlink_benchmark | 1.3 ms | 7 µs |  -99.5% |

Release Notes:

- terminal: New settings to allow customizing the set of regexes used to
identify path hyperlinks in terminal
- terminal: Fixed terminal path hyperlinks for paths containing unicode
punctuation and emoji, e.g. mojo.🔥
- terminal: Fixed path hyperlinks for Windows verbatim paths, for
example, `\\?\C:\Over\here.rs`
- terminal: Improved terminal hyperlink performance, especially for
terminals with a lot of content visible
2025-11-21 14:01:06 -05:00

1670 lines
67 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use alacritty_terminal::{
Term,
event::EventListener,
grid::Dimensions,
index::{Boundary, Column, Direction as AlacDirection, Point as AlacPoint},
term::{
cell::Flags,
search::{Match, RegexIter, RegexSearch},
},
};
use fancy_regex::Regex;
use log::{info, warn};
use std::{
ops::{Index, Range},
time::{Duration, Instant},
};
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{-}\^⟨⟩`']+"#;
const WIDE_CHAR_SPACERS: Flags =
Flags::from_bits(Flags::LEADING_WIDE_CHAR_SPACER.bits() | Flags::WIDE_CHAR_SPACER.bits())
.unwrap();
pub(super) struct RegexSearches {
url_regex: RegexSearch,
path_hyperlink_regexes: Vec<Regex>,
path_hyperlink_timeout: Duration,
}
impl Default for RegexSearches {
fn default() -> Self {
Self {
url_regex: RegexSearch::new(URL_REGEX).unwrap(),
path_hyperlink_regexes: Vec::default(),
path_hyperlink_timeout: Duration::default(),
}
}
}
impl RegexSearches {
pub(super) fn new(
path_hyperlink_regexes: impl IntoIterator<Item: AsRef<str>>,
path_hyperlink_timeout_ms: u64,
) -> Self {
Self {
url_regex: RegexSearch::new(URL_REGEX).unwrap(),
path_hyperlink_regexes: path_hyperlink_regexes
.into_iter()
.filter_map(|regex| {
Regex::new(regex.as_ref())
.inspect_err(|error| {
warn!(
concat!(
"Ignoring path hyperlink regex specified in ",
"`terminal.path_hyperlink_regexes`:\n\n\t{}\n\nError: {}",
),
regex.as_ref(),
error
);
})
.ok()
})
.collect(),
path_hyperlink_timeout: Duration::from_millis(path_hyperlink_timeout_ms),
}
}
}
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 let Some(ref url) = link {
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 = url.uri().to_owned();
let url_match = min_index..=max_index;
Some((url, true, url_match))
} else {
let (line_start, line_end) = (term.line_search_left(point), term.line_search_right(point));
if let Some((url, url_match)) = RegexIter::new(
line_start,
line_end,
AlacDirection::Right,
term,
&mut regex_searches.url_regex,
)
.find(|rm| rm.contains(&point))
.map(|url_match| {
let url = term.bounds_to_string(*url_match.start(), *url_match.end());
sanitize_url_punctuation(url, url_match, term)
}) {
Some((url, true, url_match))
} else {
path_match(
&term,
line_start,
line_end,
point,
&mut regex_searches.path_hyperlink_regexes,
regex_searches.path_hyperlink_timeout,
)
.map(|(path, path_match)| (path, false, path_match))
}
};
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 sanitize_url_punctuation<T: EventListener>(
url: String,
url_match: Match,
term: &Term<T>,
) -> (String, Match) {
let mut sanitized_url = url;
let mut chars_trimmed = 0;
// First, handle parentheses balancing using single traversal
let (open_parens, close_parens) =
sanitized_url
.chars()
.fold((0, 0), |(opens, closes), c| match c {
'(' => (opens + 1, closes),
')' => (opens, closes + 1),
_ => (opens, closes),
});
// Trim unbalanced closing parentheses
if close_parens > open_parens {
let mut remaining_close = close_parens;
while sanitized_url.ends_with(')') && remaining_close > open_parens {
sanitized_url.pop();
chars_trimmed += 1;
remaining_close -= 1;
}
}
// Handle trailing periods
if sanitized_url.ends_with('.') {
let trailing_periods = sanitized_url
.chars()
.rev()
.take_while(|&c| c == '.')
.count();
if trailing_periods > 1 {
sanitized_url.truncate(sanitized_url.len() - trailing_periods);
chars_trimmed += trailing_periods;
} else if trailing_periods == 1
&& let Some(second_last_char) = sanitized_url.chars().rev().nth(1)
&& (second_last_char.is_alphanumeric() || second_last_char == '/')
{
sanitized_url.pop();
chars_trimmed += 1;
}
}
if chars_trimmed > 0 {
let new_end = url_match.end().sub(term, Boundary::Grid, chars_trimmed);
let sanitized_match = Match::new(*url_match.start(), new_end);
(sanitized_url, sanitized_match)
} else {
(sanitized_url, url_match)
}
}
fn path_match<T>(
term: &Term<T>,
line_start: AlacPoint,
line_end: AlacPoint,
hovered: AlacPoint,
path_hyperlink_regexes: &mut Vec<Regex>,
path_hyperlink_timeout: Duration,
) -> Option<(String, Match)> {
if path_hyperlink_regexes.is_empty() || path_hyperlink_timeout.as_millis() == 0 {
return None;
}
let search_start_time = Instant::now();
let timed_out = || {
let elapsed_time = Instant::now().saturating_duration_since(search_start_time);
(elapsed_time > path_hyperlink_timeout)
.then_some((elapsed_time.as_millis(), path_hyperlink_timeout.as_millis()))
};
// This used to be: `let line = term.bounds_to_string(line_start, line_end)`, however, that
// api compresses tab characters into a single space, whereas we require a cell accurate
// string representation of the line. The below algorithm does this, but seems a bit odd.
// Maybe there is a clean api for doing this, but I couldn't find it.
let mut line = String::with_capacity(
(line_end.line.0 - line_start.line.0 + 1) as usize * term.grid().columns(),
);
line.push(term.grid()[line_start].c);
for cell in term.grid().iter_from(line_start) {
if cell.point > line_end {
break;
}
if !cell.flags.intersects(WIDE_CHAR_SPACERS) {
line.push(match cell.c {
'\t' => ' ',
c @ _ => c,
});
}
}
let line = line.trim_ascii_end();
let found_from_range = |path_range: Range<usize>,
link_range: Range<usize>,
position: Option<(u32, Option<u32>)>| {
let advance_point_by_str = |mut point: AlacPoint, s: &str| {
for _ in s.chars() {
point = term
.expand_wide(point, AlacDirection::Right)
.add(term, Boundary::Grid, 1);
}
// There does not appear to be an alacritty api that is
// "move to start of current wide char", so we have to do it ourselves.
let flags = term.grid().index(point).flags;
if flags.contains(Flags::LEADING_WIDE_CHAR_SPACER) {
AlacPoint::new(point.line + 1, Column(0))
} else if flags.contains(Flags::WIDE_CHAR_SPACER) {
AlacPoint::new(point.line, point.column - 1)
} else {
point
}
};
let link_start = advance_point_by_str(line_start, &line[..link_range.start]);
let link_end = advance_point_by_str(link_start, &line[link_range]);
let link_match = link_start
..=term
.expand_wide(link_end, AlacDirection::Left)
.sub(term, Boundary::Grid, 1);
Some((
{
let mut path = line[path_range].to_string();
position.inspect(|(line, column)| {
path += &format!(":{line}");
column.inspect(|column| path += &format!(":{column}"));
});
path
},
link_match,
))
};
for regex in path_hyperlink_regexes {
let mut path_found = false;
for captures in regex.captures_iter(&line) {
let captures = match captures {
Ok(captures) => captures,
Err(error) => {
warn!("Error '{error}' searching for path hyperlinks in line: {line}");
info!(
"Skipping match from path hyperlinks with regex: {}",
regex.as_str()
);
continue;
}
};
let match_range = captures.get(0).unwrap().range();
let (path_range, line_column) = if let Some(path) = captures.name("path") {
let parse = |name: &str| {
captures
.name(name)
.and_then(|capture| capture.as_str().parse().ok())
};
(
path.range(),
parse("line").map(|line| (line, parse("column"))),
)
} else {
(match_range.clone(), None)
};
let link_range = captures
.name("link")
.map_or(match_range, |link| link.range());
let found = found_from_range(path_range, link_range, line_column);
if let Some(found) = found {
path_found = true;
if found.1.contains(&hovered) {
return Some(found);
}
}
}
if path_found {
return None;
}
if let Some((timed_out_ms, timeout_ms)) = timed_out() {
warn!("Timed out processing path hyperlink regexes after {timed_out_ms}ms");
info!("{timeout_ms}ms time out specified in `terminal.path_hyperlink_timeout_ms`");
return None;
}
}
None
}
#[cfg(test)]
mod tests {
use crate::terminal_settings::TerminalSettings;
use super::*;
use alacritty_terminal::{
event::VoidListener,
grid::Dimensions,
index::{Boundary, Column, Line, Point as AlacPoint},
term::{Config, cell::Flags, test::TermSize},
vte::ansi::Handler,
};
use fancy_regex::Regex;
use settings::{self, Settings, SettingsContent};
use std::{cell::RefCell, ops::RangeInclusive, path::PathBuf, rc::Rc};
use url::Url;
use util::paths::PathWithPosition;
fn re_test(re: &str, hay: &str, expected: Vec<&str>) {
let results: Vec<_> = Regex::new(re)
.unwrap()
.find_iter(hay)
.map(|m| m.unwrap().as_str())
.collect();
assert_eq!(results, expected);
}
#[test]
fn test_url_regex() {
re_test(
URL_REGEX,
"test http://example.com test 'https://website1.com' test mailto:bob@example.com train",
vec![
"http://example.com",
"https://website1.com",
"mailto:bob@example.com",
],
);
}
#[test]
fn test_url_parentheses_sanitization() {
// Test our sanitize_url_parentheses function directly
let test_cases = vec![
// Cases that should be sanitized (unbalanced parentheses)
("https://www.google.com/)", "https://www.google.com/"),
("https://example.com/path)", "https://example.com/path"),
("https://test.com/))", "https://test.com/"),
// Cases that should NOT be sanitized (balanced parentheses)
(
"https://en.wikipedia.org/wiki/Example_(disambiguation)",
"https://en.wikipedia.org/wiki/Example_(disambiguation)",
),
("https://test.com/(hello)", "https://test.com/(hello)"),
(
"https://example.com/path(1)(2)",
"https://example.com/path(1)(2)",
),
// Edge cases
("https://test.com/", "https://test.com/"),
("https://example.com", "https://example.com"),
];
for (input, expected) in test_cases {
// Create a minimal terminal for testing
let term = Term::new(Config::default(), &TermSize::new(80, 24), VoidListener);
// Create a dummy match that spans the entire input
let start_point = AlacPoint::new(Line(0), Column(0));
let end_point = AlacPoint::new(Line(0), Column(input.len()));
let dummy_match = Match::new(start_point, end_point);
let (result, _) = sanitize_url_punctuation(input.to_string(), dummy_match, &term);
assert_eq!(result, expected, "Failed for input: {}", input);
}
}
#[test]
fn test_url_periods_sanitization() {
// Test URLs with trailing periods (sentence punctuation)
let test_cases = vec![
// Cases that should be sanitized (trailing periods likely punctuation)
("https://example.com.", "https://example.com"),
(
"https://github.com/zed-industries/zed.",
"https://github.com/zed-industries/zed",
),
(
"https://example.com/path/file.html.",
"https://example.com/path/file.html",
),
(
"https://example.com/file.pdf.",
"https://example.com/file.pdf",
),
("https://example.com:8080.", "https://example.com:8080"),
("https://example.com..", "https://example.com"),
(
"https://en.wikipedia.org/wiki/C.E.O.",
"https://en.wikipedia.org/wiki/C.E.O",
),
// Cases that should NOT be sanitized (periods are part of URL structure)
(
"https://example.com/v1.0/api",
"https://example.com/v1.0/api",
),
("https://192.168.1.1", "https://192.168.1.1"),
("https://sub.domain.com", "https://sub.domain.com"),
];
for (input, expected) in test_cases {
// Create a minimal terminal for testing
let term = Term::new(Config::default(), &TermSize::new(80, 24), VoidListener);
// Create a dummy match that spans the entire input
let start_point = AlacPoint::new(Line(0), Column(0));
let end_point = AlacPoint::new(Line(0), Column(input.len()));
let dummy_match = Match::new(start_point, end_point);
// This test should initially fail since we haven't implemented period sanitization yet
let (result, _) = sanitize_url_punctuation(input.to_string(), dummy_match, &term);
assert_eq!(result, expected, "Failed for input: {}", input);
}
}
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)));
let contains_tab_char = test_lines.iter().copied()
.map(str::chars).flatten().find(|&c| c == '\t');
let columns = if contains_tab_char.is_some() {
// This avoids tabs at end of lines causing whitespace-eating line wraps...
vec![longest_line_cells + 1]
} else {
// Alacritty has issues with 2 columns, use 3 as the minimum for now.
vec![3, longest_line_cells / 2, longest_line_cells + 1]
};
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 $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) };
}
#[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»👉):");
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»👉):");
test_path!("/test/cool.rs:4:2👉:", "What is this?");
test_path!("/test/cool.rs(4,2)👉:", "What is this?");
// 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!");
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»›");
test_path!(" File \"«/awesome.py»👉\", line «42»›: Wat?");
test_path!(" File \"«/awesome.py»\", line «4👉2»›");
}
#[test]
fn simple_with_descriptions() {
// path, line, column and description
test_path!("‹«/👉test/cool.rs»:«4»:«2»›:例Desc例例例");
test_path!("‹«/test/cool.rs»:«4»:«👉2»›:例Desc例例例");
test_path!("/test/cool.rs:4:2:例Desc例👉例例");
test_path!("‹«/👉test/cool.rs»(«4»,«2»):例Desc例例例");
test_path!("‹«/test/cool.rs»(«4»👉,«2»):例Desc例例例");
test_path!("/test/cool.rs(4,2):例Desc例👉例例");
// path, line, column and description w/extra colons
test_path!("‹«/👉test/cool.rs»:«4»:«2»›::例Desc例例例");
test_path!("‹«/test/cool.rs»:«4»:«👉2»›::例Desc例例例");
test_path!("/test/cool.rs:4:2::例Desc例👉例例");
test_path!("‹«/👉test/cool.rs»(«4»,«2»)::例Desc例例例");
test_path!("‹«/test/cool.rs»(«4»,«2»👉)::例Desc例例例");
test_path!("/test/cool.rs(4,2)::例Desc例👉例例");
}
#[test]
fn multiple_same_line() {
test_path!("‹«/👉test/cool.rs»› /test/cool.rs");
test_path!("/test/cool.rs ‹«/👉test/cool.rs»›");
test_path!(
"‹«🦀 multiple_👉same_line 🦀» 🚣«4» 🏛️«2»›: 🦀 multiple_same_line 🦀 🚣4 🏛️2:"
);
test_path!(
"🦀 multiple_same_line 🦀 🚣4 🏛️2 ‹«🦀 multiple_👉same_line 🦀» 🚣«4» 🏛️«2»›:"
);
// ls output (tab separated)
test_path!(
"‹«Carg👉o.toml»›\t\texperiments\t\tnotebooks\t\trust-toolchain.toml\ttooling"
);
test_path!(
"Cargo.toml\t\t‹«exper👉iments»›\t\tnotebooks\t\trust-toolchain.toml\ttooling"
);
test_path!(
"Cargo.toml\t\texperiments\t\t‹«note👉books»›\t\trust-toolchain.toml\ttooling"
);
test_path!(
"Cargo.toml\t\texperiments\t\tnotebooks\t\t‹«rust-t👉oolchain.toml»›\ttooling"
);
test_path!(
"Cargo.toml\t\texperiments\t\tnotebooks\t\trust-toolchain.toml\t‹«too👉ling»›"
);
}
#[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:4:2»(«1»,«618»):");
test_path!("‹«/test/co👉ol.rs»(«1»,«618»)::");
}
#[test]
fn quotes_and_brackets() {
test_path!("\"‹«/test/co👉ol.rs»:«4»›\"");
test_path!("'‹«/test/co👉ol.rs»:«4»›'");
test_path!("`‹«/test/co👉ol.rs»:«4»›`");
test_path!("[‹«/test/co👉ol.rs»:«4»›]");
test_path!("(‹«/test/co👉ol.rs»:«4»›)");
test_path!("{‹«/test/co👉ol.rs»:«4»›}");
test_path!("<‹«/test/co👉ol.rs»:«4»›>");
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»:«4»:«2»›`");
test_path!("[‹«/test/co👉ol.rs»:«4»:«2»›]");
test_path!("(‹«/test/co👉ol.rs»:«4»:«2»›)");
test_path!("{‹«/test/co👉ol.rs»:«4»:«2»›}");
test_path!("<‹«/test/co👉ol.rs»:«4»:«2»›>");
test_path!("[\"‹«/test/co👉ol.rs»:«4»:«2»›\"]");
test_path!("\"‹«/test/co👉ol.rs»(«4»)\"");
test_path!("'‹«/test/co👉ol.rs»(«4»)'");
test_path!("`‹«/test/co👉ol.rs»(«4»)`");
test_path!("[‹«/test/co👉ol.rs»(«4»)]");
test_path!("(‹«/test/co👉ol.rs»(«4»))");
test_path!("{‹«/test/co👉ol.rs»(«4»)}");
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»(«4»,«2»)`");
test_path!("[‹«/test/co👉ol.rs»(«4»,«2»)]");
test_path!("(‹«/test/co👉ol.rs»(«4»,«2»))");
test_path!("{‹«/test/co👉ol.rs»(«4»,«2»)}");
test_path!("<‹«/test/co👉ol.rs»(«4»,«2»)>");
test_path!("[\"‹«/test/co👉ol.rs»(«4»,«2»)\"]");
// Imbalanced
test_path!("([‹«/test/co👉ol.rs»:«4»›] was here...)");
test_path!("[Here's <‹«/test/co👉ol.rs»:«4»›>]");
test_path!("('‹«/test/co👉ol.rs»:«4»›' was here...)");
test_path!("[Here's `‹«/test/co👉ol.rs»:«4»›`]");
}
#[test]
fn trailing_punctuation() {
test_path!("‹«/test/co👉ol.rs»›:,..");
test_path!("/test/cool.rs:,👉..");
test_path!("‹«/test/co👉ol.rs»:«4»›:,");
test_path!("/test/cool.rs:4:👉,");
test_path!("[\"‹«/test/co👉ol.rs»:«4»›\"]:,");
test_path!("'‹«(/test/co👉ol.rs:4),,»›'..");
test_path!("('‹«/test/co👉ol.rs»:«4»›'::: was here...)");
test_path!("[Here's <‹«/test/co👉ol.rs»:«4»›>]::: ");
}
#[test]
fn word_wide_chars() {
// 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»›)");
test_path!(" Compiling Cool (‹«/👉例/Cool Spaces»›)");
test_path!(" Compiling Cool (‹«/例👈/Cool Spaces»›)");
test_path!(" Compiling Cool (‹«/👉例/Cool Spaces»:«4»:«2»›)");
test_path!(" Compiling Cool (‹«/例👈/Cool Spaces»(«4»,«2»))");
test_path!(" --> ‹«/👉例/Cool Spaces»›");
test_path!(" ::: ‹«/例👈/Cool Spaces»›");
test_path!(" --> ‹«/👉例/Cool Spaces»:«4»:«2»›");
test_path!(" ::: ‹«/例👈/Cool Spaces»(«4»,«2»)");
test_path!(" panicked at ‹«/👉例/Cool Spaces»:«4»:«2»›:");
test_path!(" panicked at ‹«/例👈/Cool Spaces»(«4»,«2»):");
test_path!(" at ‹«/👉例/Cool Spaces»:«4»:«2»›");
test_path!(" at ‹«/例👈/Cool Spaces»(«4»,«2»)");
// 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]
fn non_word_wide_chars() {
// Mojo diagnostic message
test_path!(" File \"«/awe👉some.🔥»\", line «42»›: Wat?");
test_path!(" File \"«/awesome👉.🔥»\", line «42»›: Wat?");
test_path!(" File \"«/awesome.👉🔥»\", line «42»›: Wat?");
test_path!(" File \"«/awesome.🔥👈»\", line «42»›: Wat?");
}
/// These likely rise to the level of being worth fixing.
mod issues {
#[test]
// <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]
// <https://github.com/zed-industries/zed/issues/12338>
fn issue_12338_regex() {
// Issue #12338
test_path!(".rw-r--r-- 0 staff 05-27 14:03 ‹«'test file 👉1.txt'»›");
test_path!(".rw-r--r-- 0 staff 05-27 14:03 ‹«👉'test file 1.txt'»›");
}
#[test]
// <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]
// <https://github.com/zed-industries/zed/issues/40202>
fn issue_40202() {
// Elixir
test_path!("[‹«lib/blitz_apex_👉server/stats/aggregate_rank_stats.ex»:«35»›: BlitzApexServer.Stats.AggregateRankStats.update/2]
1 #=> 1");
}
#[test]
// <https://github.com/zed-industries/zed/issues/28194>
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>'"
);
}
#[test]
#[cfg_attr(
not(target_os = "windows"),
should_panic(
expected = "Path = «/test/cool.rs:4:NotDesc», at grid cells (0, 1)..=(7, 2)"
)
)]
#[cfg_attr(
target_os = "windows",
should_panic(
expected = r#"Path = «C:\\test\\cool.rs:4:NotDesc», at grid cells (0, 1)..=(8, 1)"#
)
)]
// PathWithPosition::parse_str considers "/test/co👉ol.rs:4:NotDesc" invalid input, but
// still succeeds and truncates the part after the position. Ideally this would be
// parsed as the path "/test/co👉ol.rs:4:NotDesc" with no position.
fn path_with_position_parse_str() {
test_path!("`‹«/test/co👉ol.rs:4:NotDesc»›`");
test_path!("<‹«/test/co👉ol.rs:4:NotDesc»›>");
test_path!("'‹«(/test/co👉ol.rs:4:2)»›'");
test_path!("'‹«(/test/co👉ol.rs(4))»›'");
test_path!("'‹«(/test/co👉ol.rs(4,2))»›'");
}
}
/// Minor issues arguably not important enough to fix/workaround...
mod nits {
#[test]
fn alacritty_bugs_with_two_columns() {
test_path!("‹«/👉test/cool.rs»(«4»)");
test_path!("‹«/test/cool.rs»(«👉4»)");
test_path!("‹«/test/cool.rs»(«4»,«👉2»)");
// Python
test_path!("‹«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]
#[cfg_attr(
not(target_os = "windows"),
should_panic(expected = "Path = «/te:st/co:ol.r:s:4:2::::::»")
)]
#[cfg_attr(
target_os = "windows",
should_panic(expected = r#"Path = «C:\\te:st\\co:ol.r:s:4:2::::::»"#)
)]
fn many_trailing_colons_should_be_parsed_as_part_of_the_path() {
test_path!("‹«/te:st/👉co:ol.r:s:4:2::::::»›");
test_path!("/test/cool.rs:::👉:");
}
}
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 default_prompts() {
// Windows command prompt
test_path!(r#"‹«C:\Users\someone\👉test»›>"#);
test_path!(r#"C:\Users\someone\test👉>"#);
// Windows PowerShell
test_path!(r#"PS ‹«C:\Users\someone\👉test\cool.rs»›>"#);
test_path!(r#"PS C:\Users\someone\test\cool.rs👉>"#);
}
#[test]
fn unc() {
test_path!(r#"‹«\\server\share\👉test\cool.rs»›"#);
test_path!(r#"‹«\\server\share\test\cool👉.rs»›"#);
}
mod issues {
#[test]
fn issue_verbatim() {
test_path!(r#"‹«\\?\C:\👉test\cool.rs»›"#);
test_path!(r#"‹«\\?\C:\test\cool👉.rs»›"#);
}
#[test]
fn issue_verbatim_unc() {
test_path!(r#"‹«\\?\UNC\server\share\👉test\cool.rs»›"#);
test_path!(r#"‹«\\?\UNC\server\share\test\cool👉.rs»›"#);
}
}
}
mod perf {
use super::super::*;
use crate::TerminalSettings;
use alacritty_terminal::{
event::VoidListener,
grid::Dimensions,
index::{Column, Point as AlacPoint},
term::test::mock_term,
term::{Term, search::Match},
};
use settings::{self, Settings, SettingsContent};
use std::{cell::RefCell, rc::Rc};
use util_macros::perf;
fn build_test_term(line: &str) -> (Term<VoidListener>, AlacPoint) {
let content = line.repeat(500);
let term = mock_term(&content);
let point = AlacPoint::new(
term.grid().bottommost_line() - 1,
Column(term.grid().last_column().0 / 2),
);
(term, point)
}
#[perf]
pub fn cargo_hyperlink_benchmark() {
const LINE: &str = " Compiling terminal v0.1.0 (/Hyperlinks/Bench/Source/zed-hyperlinks/crates/terminal)\r\n";
thread_local! {
static TEST_TERM_AND_POINT: (Term<VoidListener>, AlacPoint) =
build_test_term(LINE);
}
TEST_TERM_AND_POINT.with(|(term, point)| {
assert!(
find_from_grid_point_bench(term, *point).is_some(),
"Hyperlink should have been found"
);
});
}
#[perf]
pub fn rust_hyperlink_benchmark() {
const LINE: &str = " --> /Hyperlinks/Bench/Source/zed-hyperlinks/crates/terminal/terminal.rs:1000:42\r\n";
thread_local! {
static TEST_TERM_AND_POINT: (Term<VoidListener>, AlacPoint) =
build_test_term(LINE);
}
TEST_TERM_AND_POINT.with(|(term, point)| {
assert!(
find_from_grid_point_bench(term, *point).is_some(),
"Hyperlink should have been found"
);
});
}
#[perf]
pub fn ls_hyperlink_benchmark() {
const LINE: &str = "Cargo.toml experiments notebooks rust-toolchain.toml tooling\r\n";
thread_local! {
static TEST_TERM_AND_POINT: (Term<VoidListener>, AlacPoint) =
build_test_term(LINE);
}
TEST_TERM_AND_POINT.with(|(term, point)| {
assert!(
find_from_grid_point_bench(term, *point).is_some(),
"Hyperlink should have been found"
);
});
}
pub fn find_from_grid_point_bench(
term: &Term<VoidListener>,
point: AlacPoint,
) -> Option<(String, bool, Match)> {
const PATH_HYPERLINK_TIMEOUT_MS: u64 = 1000;
thread_local! {
static TEST_REGEX_SEARCHES: RefCell<RegexSearches> =
RefCell::new({
let default_settings_content: Rc<SettingsContent> =
settings::parse_json_with_comments(&settings::default_settings())
.unwrap();
let default_terminal_settings =
TerminalSettings::from_settings(&default_settings_content);
RegexSearches::new(
&default_terminal_settings.path_hyperlink_regexes,
PATH_HYPERLINK_TIMEOUT_MS
)
});
}
TEST_REGEX_SEARCHES.with(|regex_searches| {
find_from_grid_point(&term, point, &mut regex_searches.borrow_mut())
})
}
}
}
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) } };
}
#[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
// https://github.com/zed-industries/zed/issues/39189
#[test]
#[should_panic(
expected = r#"Path = «C:\\test\\cool\\index.rs», at grid cells (0, 0)..=(9, 1)"#
)]
fn issue_39189() {
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) } };
}
#[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!("ipfs://例🏃🦀/cool.ipfs");
test_iri!("ipns://例🏃🦀/cool.ipns");
test_iri!("magnet://例🏃🦀/cool.git");
test_iri!("mailto:someone@somewhere.here");
test_iri!("gemini://somewhere.here");
test_iri!("gopher://somewhere.here");
test_iri!("http://例🏃🦀/cool/index.html");
test_iri!("http://10.10.10.10:1111/cool.html");
test_iri!("http://例🏃🦀/cool/index.html?amazing=1");
test_iri!("http://例🏃🦀/cool/index.html#right%20here");
test_iri!("http://例🏃🦀/cool/index.html?amazing=1#right%20here");
test_iri!("https://例🏃🦀/cool/index.html");
test_iri!("https://10.10.10.10:1111/cool.html");
test_iri!("https://例🏃🦀/cool/index.html?amazing=1");
test_iri!("https://例🏃🦀/cool/index.html#right%20here");
test_iri!("https://例🏃🦀/cool/index.html?amazing=1#right%20here");
test_iri!("news://例🏃🦀/cool.news");
test_iri!("git://例/cool.git");
test_iri!("ssh://user@somewhere.over.here:12345/例🏃🦀/cool.git");
test_iri!("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 = point.sub(term, Boundary::Grid, 1);
}
if grid.index(point).flags.contains(Flags::WIDE_CHAR_SPACER) {
point.column -= 1;
}
point
}
fn end_point_from_prev_input_point(
term: &Term<VoidListener>,
prev_input_point: AlacPoint,
) -> AlacPoint {
if term
.grid()
.index(prev_input_point)
.flags
.contains(Flags::WIDE_CHAR)
{
prev_input_point.add(term, Boundary::Grid, 1)
} else {
prev_input_point
}
}
fn process_input(term: &mut Term<VoidListener>, c: char) {
match c {
'\t' => term.put_tab(1),
c @ _ => term.input(c),
}
}
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,
end_point_from_prev_input_point(&term, 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
..=end_point_from_prev_input_point(&term, 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(':');
process_input(&mut term, c);
} else {
process_input(&mut term, 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().into_owned();
}
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,
'\t' => 8, // it's really 0-8, use the max always
_ => 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;
if index > 0 && remainder == 0 {
first_header_row.push_str(&format!("{:>10}", (index / 10)));
}
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(' ');
}
}
let remainder = (self.term.columns() - 1) % 10;
if remainder != 0 {
first_header_row.push_str(&" ".repeat(remainder));
}
result += &format!("\n [ {}]\n", first_header_row);
result += &format!(" [{}]\n", second_header_row);
result += &format!(" {}", marker_header_row);
for cell in self
.term
.renderable_content()
.display_iter
.filter(|cell| !cell.flags.intersects(WIDE_CHAR_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());
}
match cell.c {
'\t' => result.push(' '),
c @ _ => result.push(c),
}
}
result
}
}
fn test_hyperlink<'a>(
columns: usize,
total_cells: usize,
test_lines: impl Iterator<Item = &'a str>,
hyperlink_kind: HyperlinkKind,
source_location: &str,
) {
const CARGO_DIR_REGEX: &str =
r#"\s+(Compiling|Checking|Documenting) [^(]+\((?<link>(?<path>.+))\)"#;
const RUST_DIAGNOSTIC_REGEX: &str = r#"\s+(-->|:::|at) (?<link>(?<path>.+?))(:$|$)"#;
const ISSUE_12338_REGEX: &str =
r#"[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2} (?<link>(?<path>.+))"#;
const MULTIPLE_SAME_LINE_REGEX: &str =
r#"(?<link>(?<path>🦀 multiple_same_line 🦀) 🚣(?<line>[0-9]+) 🏛(?<column>[0-9]+)):"#;
const PATH_HYPERLINK_TIMEOUT_MS: u64 = 1000;
thread_local! {
static TEST_REGEX_SEARCHES: RefCell<RegexSearches> =
RefCell::new({
let default_settings_content: Rc<SettingsContent> =
settings::parse_json_with_comments(&settings::default_settings()).unwrap();
let default_terminal_settings = TerminalSettings::from_settings(&default_settings_content);
RegexSearches::new([
RUST_DIAGNOSTIC_REGEX,
CARGO_DIR_REGEX,
ISSUE_12338_REGEX,
MULTIPLE_SAME_LINE_REGEX,
]
.into_iter()
.chain(default_terminal_settings.path_hyperlink_regexes
.iter()
.map(AsRef::as_ref)),
PATH_HYPERLINK_TIMEOUT_MS)
});
}
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);
}
None => {
if expected_hyperlink.hyperlink_match.start()
!= expected_hyperlink.hyperlink_match.end()
{
assert!(
false,
"No hyperlink found\n at {source_location}:\n{}",
check_hyperlink_match.format_renderable_content()
)
}
}
}
}
}