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