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