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