1use alacritty_terminal::{
2 Term,
3 event::EventListener,
4 grid::Dimensions,
5 index::{Boundary, Column, Direction as AlacDirection, Line, Point as AlacPoint},
6 term::search::{Match, RegexIter, RegexSearch},
7};
8use regex::Regex;
9use std::{ops::Index, sync::LazyLock};
10
11const 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{-}\^⟨⟩`']+"#;
12// Optional suffix matches MSBuild diagnostic suffixes for path parsing in PathLikeWithPosition
13// https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-diagnostic-format-for-tasks
14const WORD_REGEX: &str =
15 r#"[\$\+\w.\[\]:/\\@\-~()]+(?:\((?:\d+|\d+,\d+)\))|[\$\+\w.\[\]:/\\@\-~()]+"#;
16
17const PYTHON_FILE_LINE_REGEX: &str = r#"File "(?P<file>[^"]+)", line (?P<line>\d+)"#;
18
19static PYTHON_FILE_LINE_MATCHER: LazyLock<Regex> =
20 LazyLock::new(|| Regex::new(PYTHON_FILE_LINE_REGEX).unwrap());
21
22fn python_extract_path_and_line(input: &str) -> Option<(&str, u32)> {
23 if let Some(captures) = PYTHON_FILE_LINE_MATCHER.captures(input) {
24 let path_part = captures.name("file")?.as_str();
25
26 let line_number: u32 = captures.name("line")?.as_str().parse().ok()?;
27 return Some((path_part, line_number));
28 }
29 None
30}
31
32pub(super) struct RegexSearches {
33 url_regex: RegexSearch,
34 word_regex: RegexSearch,
35 python_file_line_regex: RegexSearch,
36}
37
38impl RegexSearches {
39 pub(super) fn new() -> Self {
40 Self {
41 url_regex: RegexSearch::new(URL_REGEX).unwrap(),
42 word_regex: RegexSearch::new(WORD_REGEX).unwrap(),
43 python_file_line_regex: RegexSearch::new(PYTHON_FILE_LINE_REGEX).unwrap(),
44 }
45 }
46}
47
48pub(super) fn find_from_grid_point<T: EventListener>(
49 term: &Term<T>,
50 point: AlacPoint,
51 regex_searches: &mut RegexSearches,
52) -> Option<(String, bool, Match)> {
53 let grid = term.grid();
54 let link = grid.index(point).hyperlink();
55 let found_word = if let Some(ref url) = link {
56 let mut min_index = point;
57 loop {
58 let new_min_index = min_index.sub(term, Boundary::Cursor, 1);
59 if new_min_index == min_index || grid.index(new_min_index).hyperlink() != link {
60 break;
61 } else {
62 min_index = new_min_index
63 }
64 }
65
66 let mut max_index = point;
67 loop {
68 let new_max_index = max_index.add(term, Boundary::Cursor, 1);
69 if new_max_index == max_index || grid.index(new_max_index).hyperlink() != link {
70 break;
71 } else {
72 max_index = new_max_index
73 }
74 }
75
76 let url = url.uri().to_owned();
77 let url_match = min_index..=max_index;
78
79 Some((url, true, url_match))
80 } else if let Some(url_match) = regex_match_at(term, point, &mut regex_searches.url_regex) {
81 let url = term.bounds_to_string(*url_match.start(), *url_match.end());
82 Some((url, true, url_match))
83 } else if let Some(python_match) =
84 regex_match_at(term, point, &mut regex_searches.python_file_line_regex)
85 {
86 let matching_line = term.bounds_to_string(*python_match.start(), *python_match.end());
87 python_extract_path_and_line(&matching_line).map(|(file_path, line_number)| {
88 (format!("{file_path}:{line_number}"), false, python_match)
89 })
90 } else if let Some(word_match) = regex_match_at(term, point, &mut regex_searches.word_regex) {
91 let file_path = term.bounds_to_string(*word_match.start(), *word_match.end());
92
93 let (sanitized_match, sanitized_word) = 'sanitize: {
94 let mut word_match = word_match;
95 let mut file_path = file_path;
96
97 if is_path_surrounded_by_common_symbols(&file_path) {
98 word_match = Match::new(
99 word_match.start().add(term, Boundary::Grid, 1),
100 word_match.end().sub(term, Boundary::Grid, 1),
101 );
102 file_path = file_path[1..file_path.len() - 1].to_owned();
103 }
104
105 while file_path.ends_with(':') {
106 file_path.pop();
107 word_match = Match::new(
108 *word_match.start(),
109 word_match.end().sub(term, Boundary::Grid, 1),
110 );
111 }
112 let mut colon_count = 0;
113 for c in file_path.chars() {
114 if c == ':' {
115 colon_count += 1;
116 }
117 }
118 // strip trailing comment after colon in case of
119 // file/at/path.rs:row:column:description or error message
120 // so that the file path is `file/at/path.rs:row:column`
121 if colon_count > 2 {
122 let last_index = file_path.rfind(':').unwrap();
123 let prev_is_digit = last_index > 0
124 && file_path
125 .chars()
126 .nth(last_index - 1)
127 .is_some_and(|c| c.is_ascii_digit());
128 let next_is_digit = last_index < file_path.len() - 1
129 && file_path
130 .chars()
131 .nth(last_index + 1)
132 .is_none_or(|c| c.is_ascii_digit());
133 if prev_is_digit && !next_is_digit {
134 let stripped_len = file_path.len() - last_index;
135 word_match = Match::new(
136 *word_match.start(),
137 word_match.end().sub(term, Boundary::Grid, stripped_len),
138 );
139 file_path = file_path[0..last_index].to_owned();
140 }
141 }
142
143 break 'sanitize (word_match, file_path);
144 };
145
146 Some((sanitized_word, false, sanitized_match))
147 } else {
148 None
149 };
150
151 found_word.map(|(maybe_url_or_path, is_url, word_match)| {
152 if is_url {
153 // Treat "file://" IRIs like file paths to ensure
154 // that line numbers at the end of the path are
155 // handled correctly
156 if let Some(path) = maybe_url_or_path.strip_prefix("file://") {
157 (path.to_string(), false, word_match)
158 } else {
159 (maybe_url_or_path, true, word_match)
160 }
161 } else {
162 (maybe_url_or_path, false, word_match)
163 }
164 })
165}
166
167fn is_path_surrounded_by_common_symbols(path: &str) -> bool {
168 // Avoid detecting `[]` or `()` strings as paths, surrounded by common symbols
169 path.len() > 2
170 // The rest of the brackets and various quotes cannot be matched by the [`WORD_REGEX`] hence not checked for.
171 && (path.starts_with('[') && path.ends_with(']')
172 || path.starts_with('(') && path.ends_with(')'))
173}
174
175/// Based on alacritty/src/display/hint.rs > regex_match_at
176/// Retrieve the match, if the specified point is inside the content matching the regex.
177fn regex_match_at<T>(term: &Term<T>, point: AlacPoint, regex: &mut RegexSearch) -> Option<Match> {
178 visible_regex_match_iter(term, regex).find(|rm| rm.contains(&point))
179}
180
181/// Copied from alacritty/src/display/hint.rs:
182/// Iterate over all visible regex matches.
183fn visible_regex_match_iter<'a, T>(
184 term: &'a Term<T>,
185 regex: &'a mut RegexSearch,
186) -> impl Iterator<Item = Match> + 'a {
187 const MAX_SEARCH_LINES: usize = 100;
188
189 let viewport_start = Line(-(term.grid().display_offset() as i32));
190 let viewport_end = viewport_start + term.bottommost_line();
191 let mut start = term.line_search_left(AlacPoint::new(viewport_start, Column(0)));
192 let mut end = term.line_search_right(AlacPoint::new(viewport_end, Column(0)));
193 start.line = start.line.max(viewport_start - MAX_SEARCH_LINES);
194 end.line = end.line.min(viewport_end + MAX_SEARCH_LINES);
195
196 RegexIter::new(start, end, AlacDirection::Right, term, regex)
197 .skip_while(move |rm| rm.end().line < viewport_start)
198 .take_while(move |rm| rm.start().line <= viewport_end)
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use alacritty_terminal::{
205 event::VoidListener,
206 index::{Boundary, Point as AlacPoint},
207 term::{Config, cell::Flags, test::TermSize},
208 vte::ansi::Handler,
209 };
210 use std::{cell::RefCell, ops::RangeInclusive, path::PathBuf};
211 use url::Url;
212 use util::paths::PathWithPosition;
213
214 fn re_test(re: &str, hay: &str, expected: Vec<&str>) {
215 let results: Vec<_> = regex::Regex::new(re)
216 .unwrap()
217 .find_iter(hay)
218 .map(|m| m.as_str())
219 .collect();
220 assert_eq!(results, expected);
221 }
222
223 #[test]
224 fn test_url_regex() {
225 re_test(
226 URL_REGEX,
227 "test http://example.com test 'https://website1.com' test mailto:bob@example.com train",
228 vec![
229 "http://example.com",
230 "https://website1.com",
231 "mailto:bob@example.com",
232 ],
233 );
234 }
235
236 #[test]
237 fn test_word_regex() {
238 re_test(
239 WORD_REGEX,
240 "hello, world! \"What\" is this?",
241 vec!["hello", "world", "What", "is", "this"],
242 );
243 }
244
245 #[test]
246 fn test_word_regex_with_linenum() {
247 // filename(line) and filename(line,col) as used in MSBuild output
248 // should be considered a single "word", even though comma is
249 // usually a word separator
250 re_test(WORD_REGEX, "a Main.cs(20) b", vec!["a", "Main.cs(20)", "b"]);
251 re_test(
252 WORD_REGEX,
253 "Main.cs(20,5) Error desc",
254 vec!["Main.cs(20,5)", "Error", "desc"],
255 );
256 // filename:line:col is a popular format for unix tools
257 re_test(
258 WORD_REGEX,
259 "a Main.cs:20:5 b",
260 vec!["a", "Main.cs:20:5", "b"],
261 );
262 // Some tools output "filename:line:col:message", which currently isn't
263 // handled correctly, but might be in the future
264 re_test(
265 WORD_REGEX,
266 "Main.cs:20:5:Error desc",
267 vec!["Main.cs:20:5:Error", "desc"],
268 );
269 }
270
271 #[test]
272 fn test_python_file_line_regex() {
273 re_test(
274 PYTHON_FILE_LINE_REGEX,
275 "hay File \"/zed/bad_py.py\", line 8 stack",
276 vec!["File \"/zed/bad_py.py\", line 8"],
277 );
278 re_test(PYTHON_FILE_LINE_REGEX, "unrelated", vec![]);
279 }
280
281 #[test]
282 fn test_python_file_line() {
283 let inputs: Vec<(&str, Option<(&str, u32)>)> = vec![
284 (
285 "File \"/zed/bad_py.py\", line 8",
286 Some(("/zed/bad_py.py", 8u32)),
287 ),
288 ("File \"path/to/zed/bad_py.py\"", None),
289 ("unrelated", None),
290 ("", None),
291 ];
292 let actual = inputs
293 .iter()
294 .map(|input| python_extract_path_and_line(input.0))
295 .collect::<Vec<_>>();
296 let expected = inputs.iter().map(|(_, output)| *output).collect::<Vec<_>>();
297 assert_eq!(actual, expected);
298 }
299
300 // We use custom columns in many tests to workaround this issue by ensuring a wrapped
301 // line never ends on a wide char:
302 //
303 // <https://github.com/alacritty/alacritty/issues/8586>
304 //
305 // This issue was recently fixed, as soon as we update to a version containing the fix we
306 // can remove all the custom columns from these tests.
307 //
308 macro_rules! test_hyperlink {
309 ($($lines:expr),+; $hyperlink_kind:ident) => { {
310 use crate::terminal_hyperlinks::tests::line_cells_count;
311 use std::cmp;
312
313 let test_lines = vec![$($lines),+];
314 let (total_cells, longest_line_cells) =
315 test_lines.iter().copied()
316 .map(line_cells_count)
317 .fold((0, 0), |state, cells| (state.0 + cells, cmp::max(state.1, cells)));
318
319 test_hyperlink!(
320 // Alacritty has issues with 2 columns, use 3 as the minimum for now.
321 [3, longest_line_cells / 2, longest_line_cells + 1];
322 total_cells;
323 test_lines.iter().copied();
324 $hyperlink_kind
325 )
326 } };
327
328 ($($columns:literal),+; $($lines:expr),+; $hyperlink_kind:ident) => { {
329 use crate::terminal_hyperlinks::tests::line_cells_count;
330
331 let test_lines = vec![$($lines),+];
332 let total_cells = test_lines.iter().copied().map(line_cells_count).sum();
333
334 test_hyperlink!(
335 [ $($columns),+ ]; total_cells; test_lines.iter().copied(); $hyperlink_kind
336 )
337 } };
338
339 ([ $($columns:expr),+ ]; $total_cells:expr; $lines:expr; $hyperlink_kind:ident) => { {
340 use crate::terminal_hyperlinks::tests::{ test_hyperlink, HyperlinkKind };
341
342 let source_location = format!("{}:{}", std::file!(), std::line!());
343 for columns in vec![ $($columns),+] {
344 test_hyperlink(columns, $total_cells, $lines, HyperlinkKind::$hyperlink_kind,
345 &source_location);
346 }
347 } };
348 }
349
350 mod path {
351 /// 👉 := **hovered** on following char
352 ///
353 /// 👈 := **hovered** on wide char spacer of previous full width char
354 ///
355 /// **`‹›`** := expected **hyperlink** match
356 ///
357 /// **`«»`** := expected **path**, **row**, and **column** capture groups
358 ///
359 /// [**`c₀, c₁, …, cₙ;`**]ₒₚₜ := use specified terminal widths of `c₀, c₁, …, cₙ` **columns**
360 /// (defaults to `3, longest_line_cells / 2, longest_line_cells + 1;`)
361 ///
362 macro_rules! test_path {
363 ($($lines:literal),+) => { test_hyperlink!($($lines),+; Path) };
364 ($($columns:literal),+; $($lines:literal),+) => {
365 test_hyperlink!($($columns),+; $($lines),+; Path)
366 };
367 }
368
369 #[test]
370 fn simple() {
371 // Rust paths
372 // Just the path
373 test_path!("‹«/👉test/cool.rs»›");
374 test_path!("‹«/test/cool👉.rs»›");
375
376 // path and line
377 test_path!("‹«/👉test/cool.rs»:«4»›");
378 test_path!("‹«/test/cool.rs»👉:«4»›");
379 test_path!("‹«/test/cool.rs»:«👉4»›");
380 test_path!("‹«/👉test/cool.rs»(«4»)›");
381 test_path!("‹«/test/cool.rs»👉(«4»)›");
382 test_path!("‹«/test/cool.rs»(«👉4»)›");
383 test_path!("‹«/test/cool.rs»(«4»👉)›");
384
385 // path, line, and column
386 test_path!("‹«/👉test/cool.rs»:«4»:«2»›");
387 test_path!("‹«/test/cool.rs»:«4»:«👉2»›");
388 test_path!("‹«/👉test/cool.rs»(«4»,«2»)›");
389 test_path!("‹«/test/cool.rs»(«4»👉,«2»)›");
390
391 // path, line, column, and ':' suffix
392 test_path!("‹«/👉test/cool.rs»:«4»:«2»›:");
393 test_path!("‹«/test/cool.rs»:«4»:«👉2»›:");
394 test_path!("‹«/👉test/cool.rs»(«4»,«2»)›:");
395 test_path!("‹«/test/cool.rs»(«4»,«2»👉)›:");
396
397 // path, line, column, and description
398 test_path!("‹«/test/cool.rs»:«4»:«2»›👉:Error!");
399 test_path!("‹«/test/cool.rs»:«4»:«2»›:👉Error!");
400 test_path!("‹«/test/co👉ol.rs»(«4»,«2»)›:Error!");
401
402 // Cargo output
403 test_path!(" Compiling Cool 👉(‹«/test/Cool»›)");
404 test_path!(" Compiling Cool (‹«/👉test/Cool»›)");
405 test_path!(" Compiling Cool (‹«/test/Cool»›👉)");
406
407 // Python
408 test_path!("‹«awe👉some.py»›");
409
410 test_path!(" ‹F👉ile \"«/awesome.py»\", line «42»›: Wat?");
411 test_path!(" ‹File \"«/awe👉some.py»\", line «42»›: Wat?");
412 test_path!(" ‹File \"«/awesome.py»👉\", line «42»›: Wat?");
413 test_path!(" ‹File \"«/awesome.py»\", line «4👉2»›: Wat?");
414 }
415
416 #[test]
417 fn colons_galore() {
418 test_path!("‹«/test/co👉ol.rs»:«4»›");
419 test_path!("‹«/test/co👉ol.rs»:«4»›:");
420 test_path!("‹«/test/co👉ol.rs»:«4»:«2»›");
421 test_path!("‹«/test/co👉ol.rs»:«4»:«2»›:");
422 test_path!("‹«/test/co👉ol.rs»(«1»)›");
423 test_path!("‹«/test/co👉ol.rs»(«1»)›:");
424 test_path!("‹«/test/co👉ol.rs»(«1»,«618»)›");
425 test_path!("‹«/test/co👉ol.rs»(«1»,«618»)›:");
426 test_path!("‹«/test/co👉ol.rs»::«42»›");
427 test_path!("‹«/test/co👉ol.rs»::«42»›:");
428 test_path!("‹«/test/co👉ol.rs:4:2»(«1»,«618»)›");
429 test_path!("‹«/test/co👉ol.rs»(«1»,«618»)›::");
430 }
431
432 #[test]
433 fn word_wide_chars() {
434 // Rust paths
435 test_path!(4, 6, 12; "‹«/👉例/cool.rs»›");
436 test_path!(4, 6, 12; "‹«/例👈/cool.rs»›");
437 test_path!(4, 8, 16; "‹«/例/cool.rs»:«👉4»›");
438 test_path!(4, 8, 16; "‹«/例/cool.rs»:«4»:«👉2»›");
439
440 // Cargo output
441 test_path!(4, 27, 30; " Compiling Cool (‹«/👉例/Cool»›)");
442 test_path!(4, 27, 30; " Compiling Cool (‹«/例👈/Cool»›)");
443
444 // Python
445 test_path!(4, 11; "‹«👉例wesome.py»›");
446 test_path!(4, 11; "‹«例👈wesome.py»›");
447 test_path!(6, 17, 40; " ‹File \"«/👉例wesome.py»\", line «42»›: Wat?");
448 test_path!(6, 17, 40; " ‹File \"«/例👈wesome.py»\", line «42»›: Wat?");
449 }
450
451 #[test]
452 fn non_word_wide_chars() {
453 // Mojo diagnostic message
454 test_path!(4, 18, 38; " ‹File \"«/awe👉some.🔥»\", line «42»›: Wat?");
455 test_path!(4, 18, 38; " ‹File \"«/awesome👉.🔥»\", line «42»›: Wat?");
456 test_path!(4, 18, 38; " ‹File \"«/awesome.👉🔥»\", line «42»›: Wat?");
457 test_path!(4, 18, 38; " ‹File \"«/awesome.🔥👈»\", line «42»›: Wat?");
458 }
459
460 /// These likely rise to the level of being worth fixing.
461 mod issues {
462 #[test]
463 #[cfg_attr(not(target_os = "windows"), should_panic(expected = "Path = «例»"))]
464 #[cfg_attr(target_os = "windows", should_panic(expected = r#"Path = «C:\\例»"#))]
465 // <https://github.com/alacritty/alacritty/issues/8586>
466 fn issue_alacritty_8586() {
467 // Rust paths
468 test_path!("‹«/👉例/cool.rs»›");
469 test_path!("‹«/例👈/cool.rs»›");
470 test_path!("‹«/例/cool.rs»:«👉4»›");
471 test_path!("‹«/例/cool.rs»:«4»:«👉2»›");
472
473 // Cargo output
474 test_path!(" Compiling Cool (‹«/👉例/Cool»›)");
475 test_path!(" Compiling Cool (‹«/例👈/Cool»›)");
476
477 // Python
478 test_path!("‹«👉例wesome.py»›");
479 test_path!("‹«例👈wesome.py»›");
480 test_path!(" ‹File \"«/👉例wesome.py»\", line «42»›: Wat?");
481 test_path!(" ‹File \"«/例👈wesome.py»\", line «42»›: Wat?");
482 }
483
484 #[test]
485 #[should_panic(expected = "No hyperlink found")]
486 // <https://github.com/zed-industries/zed/issues/12338>
487 fn issue_12338() {
488 // Issue #12338
489 test_path!(".rw-r--r-- 0 staff 05-27 14:03 ‹«test👉、2.txt»›");
490 test_path!(".rw-r--r-- 0 staff 05-27 14:03 ‹«test、👈2.txt»›");
491 test_path!(".rw-r--r-- 0 staff 05-27 14:03 ‹«test👉。3.txt»›");
492 test_path!(".rw-r--r-- 0 staff 05-27 14:03 ‹«test。👈3.txt»›");
493
494 // Rust paths
495 test_path!("‹«/👉🏃/🦀.rs»›");
496 test_path!("‹«/🏃👈/🦀.rs»›");
497 test_path!("‹«/🏃/👉🦀.rs»:«4»›");
498 test_path!("‹«/🏃/🦀👈.rs»:«4»:«2»›");
499
500 // Cargo output
501 test_path!(" Compiling Cool (‹«/👉🏃/Cool»›)");
502 test_path!(" Compiling Cool (‹«/🏃👈/Cool»›)");
503
504 // Python
505 test_path!("‹«👉🏃wesome.py»›");
506 test_path!("‹«🏃👈wesome.py»›");
507 test_path!(" ‹File \"«/👉🏃wesome.py»\", line «42»›: Wat?");
508 test_path!(" ‹File \"«/🏃👈wesome.py»\", line «42»›: Wat?");
509
510 // Mojo
511 test_path!("‹«/awe👉some.🔥»› is some good Mojo!");
512 test_path!("‹«/awesome👉.🔥»› is some good Mojo!");
513 test_path!("‹«/awesome.👉🔥»› is some good Mojo!");
514 test_path!("‹«/awesome.🔥👈»› is some good Mojo!");
515 test_path!(" ‹File \"«/👉🏃wesome.🔥»\", line «42»›: Wat?");
516 test_path!(" ‹File \"«/🏃👈wesome.🔥»\", line «42»›: Wat?");
517 }
518
519 #[test]
520 #[cfg_attr(
521 not(target_os = "windows"),
522 should_panic(
523 expected = "Path = «test/controllers/template_items_controller_test.rb», line = 20, at grid cells (0, 0)..=(17, 1)"
524 )
525 )]
526 #[cfg_attr(
527 target_os = "windows",
528 should_panic(
529 expected = r#"Path = «test\\controllers\\template_items_controller_test.rb», line = 20, at grid cells (0, 0)..=(17, 1)"#
530 )
531 )]
532 // <https://github.com/zed-industries/zed/issues/28194>
533 //
534 // #28194 was closed, but the link includes the description part (":in" here), which
535 // seems wrong...
536 fn issue_28194() {
537 test_path!(
538 "‹«test/c👉ontrollers/template_items_controller_test.rb»:«20»›:in 'block (2 levels) in <class:TemplateItemsControllerTest>'"
539 );
540 test_path!(
541 "‹«test/controllers/template_items_controller_test.rb»:«19»›:i👉n 'block in <class:TemplateItemsControllerTest>'"
542 );
543 }
544 }
545
546 /// Minor issues arguably not important enough to fix/workaround...
547 mod nits {
548 #[test]
549 #[cfg_attr(
550 not(target_os = "windows"),
551 should_panic(expected = "Path = «/test/cool.rs(4»")
552 )]
553 #[cfg_attr(
554 target_os = "windows",
555 should_panic(expected = r#"Path = «C:\\test\\cool.rs(4»"#)
556 )]
557 fn alacritty_bugs_with_two_columns() {
558 test_path!(2; "‹«/👉test/cool.rs»(«4»)›");
559 test_path!(2; "‹«/test/cool.rs»(«👉4»)›");
560 test_path!(2; "‹«/test/cool.rs»(«4»,«👉2»)›");
561
562 // Python
563 test_path!(2; "‹«awe👉some.py»›");
564 }
565
566 #[test]
567 #[cfg_attr(
568 not(target_os = "windows"),
569 should_panic(
570 expected = "Path = «/test/cool.rs», line = 1, at grid cells (0, 0)..=(9, 0)"
571 )
572 )]
573 #[cfg_attr(
574 target_os = "windows",
575 should_panic(
576 expected = r#"Path = «C:\\test\\cool.rs», line = 1, at grid cells (0, 0)..=(9, 2)"#
577 )
578 )]
579 fn invalid_row_column_should_be_part_of_path() {
580 test_path!("‹«/👉test/cool.rs:1:618033988749»›");
581 test_path!("‹«/👉test/cool.rs(1,618033988749)»›");
582 }
583
584 #[test]
585 #[should_panic(expected = "Path = «»")]
586 fn colon_suffix_succeeds_in_finding_an_empty_maybe_path() {
587 test_path!("‹«/test/cool.rs»:«4»:«2»›👉:", "What is this?");
588 test_path!("‹«/test/cool.rs»(«4»,«2»)›👉:", "What is this?");
589 }
590
591 #[test]
592 #[cfg_attr(
593 not(target_os = "windows"),
594 should_panic(expected = "Path = «/test/cool.rs»")
595 )]
596 #[cfg_attr(
597 target_os = "windows",
598 should_panic(expected = r#"Path = «C:\\test\\cool.rs»"#)
599 )]
600 fn many_trailing_colons_should_be_parsed_as_part_of_the_path() {
601 test_path!("‹«/test/cool.rs:::👉:»›");
602 test_path!("‹«/te:st/👉co:ol.r:s:4:2::::::»›");
603 }
604 }
605
606 #[cfg(target_os = "windows")]
607 mod windows {
608 // Lots of fun to be had with long file paths (verbatim) and UNC paths on Windows.
609 // See <https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation>
610 // See <https://users.rust-lang.org/t/understanding-windows-paths/58583>
611 // See <https://github.com/rust-lang/cargo/issues/13919>
612
613 #[test]
614 fn unc() {
615 test_path!(r#"‹«\\server\share\👉test\cool.rs»›"#);
616 test_path!(r#"‹«\\server\share\test\cool👉.rs»›"#);
617 }
618
619 mod issues {
620 #[test]
621 #[should_panic(
622 expected = r#"Path = «C:\\test\\cool.rs», at grid cells (0, 0)..=(6, 0)"#
623 )]
624 fn issue_verbatim() {
625 test_path!(r#"‹«\\?\C:\👉test\cool.rs»›"#);
626 test_path!(r#"‹«\\?\C:\test\cool👉.rs»›"#);
627 }
628
629 #[test]
630 #[should_panic(
631 expected = r#"Path = «\\\\server\\share\\test\\cool.rs», at grid cells (0, 0)..=(10, 2)"#
632 )]
633 fn issue_verbatim_unc() {
634 test_path!(r#"‹«\\?\UNC\server\share\👉test\cool.rs»›"#);
635 test_path!(r#"‹«\\?\UNC\server\share\test\cool👉.rs»›"#);
636 }
637 }
638 }
639 }
640
641 mod file_iri {
642 // File IRIs have a ton of use cases, most of which we currently do not support. A few of
643 // those cases are documented here as tests which are expected to fail.
644 // See https://en.wikipedia.org/wiki/File_URI_scheme
645
646 /// [**`c₀, c₁, …, cₙ;`**]ₒₚₜ := use specified terminal widths of `c₀, c₁, …, cₙ` **columns**
647 /// (defaults to `3, longest_line_cells / 2, longest_line_cells + 1;`)
648 ///
649 macro_rules! test_file_iri {
650 ($file_iri:literal) => { { test_hyperlink!(concat!("‹«👉", $file_iri, "»›"); FileIri) } };
651 ($($columns:literal),+; $file_iri:literal) => { {
652 test_hyperlink!($($columns),+; concat!("‹«👉", $file_iri, "»›"); FileIri)
653 } };
654 }
655
656 #[cfg(not(target_os = "windows"))]
657 #[test]
658 fn absolute_file_iri() {
659 test_file_iri!("file:///test/cool/index.rs");
660 test_file_iri!("file:///test/cool/");
661 }
662
663 mod issues {
664 #[cfg(not(target_os = "windows"))]
665 #[test]
666 #[should_panic(expected = "Path = «/test/Ῥόδος/», at grid cells (0, 0)..=(15, 1)")]
667 fn issue_file_iri_with_percent_encoded_characters() {
668 // Non-space characters
669 // file:///test/Ῥόδος/
670 test_file_iri!("file:///test/%E1%BF%AC%CF%8C%CE%B4%CE%BF%CF%82/"); // URI
671
672 // Spaces
673 test_file_iri!("file:///te%20st/co%20ol/index.rs");
674 test_file_iri!("file:///te%20st/co%20ol/");
675 }
676 }
677
678 #[cfg(target_os = "windows")]
679 mod windows {
680 mod issues {
681 // The test uses Url::to_file_path(), but it seems that the Url crate doesn't
682 // support relative file IRIs.
683 #[test]
684 #[should_panic(
685 expected = r#"Failed to interpret file IRI `file:/test/cool/index.rs` as a path"#
686 )]
687 fn issue_relative_file_iri() {
688 test_file_iri!("file:/test/cool/index.rs");
689 test_file_iri!("file:/test/cool/");
690 }
691
692 // See https://en.wikipedia.org/wiki/File_URI_scheme
693 #[test]
694 #[should_panic(
695 expected = r#"Path = «C:\\test\\cool\\index.rs», at grid cells (0, 0)..=(9, 1)"#
696 )]
697 fn issue_absolute_file_iri() {
698 test_file_iri!("file:///C:/test/cool/index.rs");
699 test_file_iri!("file:///C:/test/cool/");
700 }
701
702 #[test]
703 #[should_panic(
704 expected = r#"Path = «C:\\test\\Ῥόδος\\», at grid cells (0, 0)..=(16, 1)"#
705 )]
706 fn issue_file_iri_with_percent_encoded_characters() {
707 // Non-space characters
708 // file:///test/Ῥόδος/
709 test_file_iri!("file:///C:/test/%E1%BF%AC%CF%8C%CE%B4%CE%BF%CF%82/"); // URI
710
711 // Spaces
712 test_file_iri!("file:///C:/te%20st/co%20ol/index.rs");
713 test_file_iri!("file:///C:/te%20st/co%20ol/");
714 }
715 }
716 }
717 }
718
719 mod iri {
720 /// [**`c₀, c₁, …, cₙ;`**]ₒₚₜ := use specified terminal widths of `c₀, c₁, …, cₙ` **columns**
721 /// (defaults to `3, longest_line_cells / 2, longest_line_cells + 1;`)
722 ///
723 macro_rules! test_iri {
724 ($iri:literal) => { { test_hyperlink!(concat!("‹«👉", $iri, "»›"); Iri) } };
725 ($($columns:literal),+; $iri:literal) => { {
726 test_hyperlink!($($columns),+; concat!("‹«👉", $iri, "»›"); Iri)
727 } };
728 }
729
730 #[test]
731 fn simple() {
732 // In the order they appear in URL_REGEX, except 'file://' which is treated as a path
733 test_iri!("ipfs://test/cool.ipfs");
734 test_iri!("ipns://test/cool.ipns");
735 test_iri!("magnet://test/cool.git");
736 test_iri!("mailto:someone@somewhere.here");
737 test_iri!("gemini://somewhere.here");
738 test_iri!("gopher://somewhere.here");
739 test_iri!("http://test/cool/index.html");
740 test_iri!("http://10.10.10.10:1111/cool.html");
741 test_iri!("http://test/cool/index.html?amazing=1");
742 test_iri!("http://test/cool/index.html#right%20here");
743 test_iri!("http://test/cool/index.html?amazing=1#right%20here");
744 test_iri!("https://test/cool/index.html");
745 test_iri!("https://10.10.10.10:1111/cool.html");
746 test_iri!("https://test/cool/index.html?amazing=1");
747 test_iri!("https://test/cool/index.html#right%20here");
748 test_iri!("https://test/cool/index.html?amazing=1#right%20here");
749 test_iri!("news://test/cool.news");
750 test_iri!("git://test/cool.git");
751 test_iri!("ssh://user@somewhere.over.here:12345/test/cool.git");
752 test_iri!("ftp://test/cool.ftp");
753 }
754
755 #[test]
756 fn wide_chars() {
757 // In the order they appear in URL_REGEX, except 'file://' which is treated as a path
758 test_iri!(4, 20; "ipfs://例🏃🦀/cool.ipfs");
759 test_iri!(4, 20; "ipns://例🏃🦀/cool.ipns");
760 test_iri!(6, 20; "magnet://例🏃🦀/cool.git");
761 test_iri!(4, 20; "mailto:someone@somewhere.here");
762 test_iri!(4, 20; "gemini://somewhere.here");
763 test_iri!(4, 20; "gopher://somewhere.here");
764 test_iri!(4, 20; "http://例🏃🦀/cool/index.html");
765 test_iri!(4, 20; "http://10.10.10.10:1111/cool.html");
766 test_iri!(4, 20; "http://例🏃🦀/cool/index.html?amazing=1");
767 test_iri!(4, 20; "http://例🏃🦀/cool/index.html#right%20here");
768 test_iri!(4, 20; "http://例🏃🦀/cool/index.html?amazing=1#right%20here");
769 test_iri!(4, 20; "https://例🏃🦀/cool/index.html");
770 test_iri!(4, 20; "https://10.10.10.10:1111/cool.html");
771 test_iri!(4, 20; "https://例🏃🦀/cool/index.html?amazing=1");
772 test_iri!(4, 20; "https://例🏃🦀/cool/index.html#right%20here");
773 test_iri!(4, 20; "https://例🏃🦀/cool/index.html?amazing=1#right%20here");
774 test_iri!(4, 20; "news://例🏃🦀/cool.news");
775 test_iri!(5, 20; "git://例/cool.git");
776 test_iri!(5, 20; "ssh://user@somewhere.over.here:12345/例🏃🦀/cool.git");
777 test_iri!(7, 20; "ftp://例🏃🦀/cool.ftp");
778 }
779
780 // There are likely more tests needed for IRI vs URI
781 #[test]
782 fn iris() {
783 // These refer to the same location, see example here:
784 // <https://en.wikipedia.org/wiki/Internationalized_Resource_Identifier#Compatibility>
785 test_iri!("https://en.wiktionary.org/wiki/Ῥόδος"); // IRI
786 test_iri!("https://en.wiktionary.org/wiki/%E1%BF%AC%CF%8C%CE%B4%CE%BF%CF%82"); // URI
787 }
788
789 #[test]
790 #[should_panic(expected = "Expected a path, but was a iri")]
791 fn file_is_a_path() {
792 test_iri!("file://test/cool/index.rs");
793 }
794 }
795
796 #[derive(Debug, PartialEq)]
797 enum HyperlinkKind {
798 FileIri,
799 Iri,
800 Path,
801 }
802
803 struct ExpectedHyperlink {
804 hovered_grid_point: AlacPoint,
805 hovered_char: char,
806 hyperlink_kind: HyperlinkKind,
807 iri_or_path: String,
808 row: Option<u32>,
809 column: Option<u32>,
810 hyperlink_match: RangeInclusive<AlacPoint>,
811 }
812
813 /// Converts to Windows style paths on Windows, like path!(), but at runtime for improved test
814 /// readability.
815 fn build_term_from_test_lines<'a>(
816 hyperlink_kind: HyperlinkKind,
817 term_size: TermSize,
818 test_lines: impl Iterator<Item = &'a str>,
819 ) -> (Term<VoidListener>, ExpectedHyperlink) {
820 #[derive(Default, Eq, PartialEq)]
821 enum HoveredState {
822 #[default]
823 HoveredScan,
824 HoveredNextChar,
825 Done,
826 }
827
828 #[derive(Default, Eq, PartialEq)]
829 enum MatchState {
830 #[default]
831 MatchScan,
832 MatchNextChar,
833 Match(AlacPoint),
834 Done,
835 }
836
837 #[derive(Default, Eq, PartialEq)]
838 enum CapturesState {
839 #[default]
840 PathScan,
841 PathNextChar,
842 Path(AlacPoint),
843 RowScan,
844 Row(String),
845 ColumnScan,
846 Column(String),
847 Done,
848 }
849
850 fn prev_input_point_from_term(term: &Term<VoidListener>) -> AlacPoint {
851 let grid = term.grid();
852 let cursor = &grid.cursor;
853 let mut point = cursor.point;
854
855 if !cursor.input_needs_wrap {
856 point.column -= 1;
857 }
858
859 if grid.index(point).flags.contains(Flags::WIDE_CHAR_SPACER) {
860 point.column -= 1;
861 }
862
863 point
864 }
865
866 let mut hovered_grid_point: Option<AlacPoint> = None;
867 let mut hyperlink_match = AlacPoint::default()..=AlacPoint::default();
868 let mut iri_or_path = String::default();
869 let mut row = None;
870 let mut column = None;
871 let mut prev_input_point = AlacPoint::default();
872 let mut hovered_state = HoveredState::default();
873 let mut match_state = MatchState::default();
874 let mut captures_state = CapturesState::default();
875 let mut term = Term::new(Config::default(), &term_size, VoidListener);
876
877 for text in test_lines {
878 let chars: Box<dyn Iterator<Item = char>> =
879 if cfg!(windows) && hyperlink_kind == HyperlinkKind::Path {
880 Box::new(text.chars().map(|c| if c == '/' { '\\' } else { c })) as _
881 } else {
882 Box::new(text.chars()) as _
883 };
884 let mut chars = chars.peekable();
885 while let Some(c) = chars.next() {
886 match c {
887 '👉' => {
888 hovered_state = HoveredState::HoveredNextChar;
889 }
890 '👈' => {
891 hovered_grid_point = Some(prev_input_point.add(&term, Boundary::Grid, 1));
892 }
893 '«' | '»' => {
894 captures_state = match captures_state {
895 CapturesState::PathScan => CapturesState::PathNextChar,
896 CapturesState::PathNextChar => {
897 panic!("Should have been handled by char input")
898 }
899 CapturesState::Path(start_point) => {
900 iri_or_path = term.bounds_to_string(start_point, prev_input_point);
901 CapturesState::RowScan
902 }
903 CapturesState::RowScan => CapturesState::Row(String::new()),
904 CapturesState::Row(number) => {
905 row = Some(number.parse::<u32>().unwrap());
906 CapturesState::ColumnScan
907 }
908 CapturesState::ColumnScan => CapturesState::Column(String::new()),
909 CapturesState::Column(number) => {
910 column = Some(number.parse::<u32>().unwrap());
911 CapturesState::Done
912 }
913 CapturesState::Done => {
914 panic!("Extra '«', '»'")
915 }
916 }
917 }
918 '‹' | '›' => {
919 match_state = match match_state {
920 MatchState::MatchScan => MatchState::MatchNextChar,
921 MatchState::MatchNextChar => {
922 panic!("Should have been handled by char input")
923 }
924 MatchState::Match(start_point) => {
925 hyperlink_match = start_point..=prev_input_point;
926 MatchState::Done
927 }
928 MatchState::Done => {
929 panic!("Extra '‹', '›'")
930 }
931 }
932 }
933 _ => {
934 if let CapturesState::Row(number) | CapturesState::Column(number) =
935 &mut captures_state
936 {
937 number.push(c)
938 }
939
940 let is_windows_abs_path_start = captures_state
941 == CapturesState::PathNextChar
942 && cfg!(windows)
943 && hyperlink_kind == HyperlinkKind::Path
944 && c == '\\'
945 && chars.peek().is_some_and(|c| *c != '\\');
946
947 if is_windows_abs_path_start {
948 // Convert Unix abs path start into Windows abs path start so that the
949 // same test can be used for both OSes.
950 term.input('C');
951 prev_input_point = prev_input_point_from_term(&term);
952 term.input(':');
953 term.input(c);
954 } else {
955 term.input(c);
956 prev_input_point = prev_input_point_from_term(&term);
957 }
958
959 if hovered_state == HoveredState::HoveredNextChar {
960 hovered_grid_point = Some(prev_input_point);
961 hovered_state = HoveredState::Done;
962 }
963 if captures_state == CapturesState::PathNextChar {
964 captures_state = CapturesState::Path(prev_input_point);
965 }
966 if match_state == MatchState::MatchNextChar {
967 match_state = MatchState::Match(prev_input_point);
968 }
969 }
970 }
971 }
972 term.move_down_and_cr(1);
973 }
974
975 if hyperlink_kind == HyperlinkKind::FileIri {
976 let Ok(url) = Url::parse(&iri_or_path) else {
977 panic!("Failed to parse file IRI `{iri_or_path}`");
978 };
979 let Ok(path) = url.to_file_path() else {
980 panic!("Failed to interpret file IRI `{iri_or_path}` as a path");
981 };
982 iri_or_path = path.to_string_lossy().to_string();
983 }
984
985 if cfg!(windows) {
986 // Handle verbatim and UNC paths for Windows
987 if let Some(stripped) = iri_or_path.strip_prefix(r#"\\?\UNC\"#) {
988 iri_or_path = format!(r#"\\{stripped}"#);
989 } else if let Some(stripped) = iri_or_path.strip_prefix(r#"\\?\"#) {
990 iri_or_path = stripped.to_string();
991 }
992 }
993
994 let hovered_grid_point = hovered_grid_point.expect("Missing hovered point (👉 or 👈)");
995 let hovered_char = term.grid().index(hovered_grid_point).c;
996 (
997 term,
998 ExpectedHyperlink {
999 hovered_grid_point,
1000 hovered_char,
1001 hyperlink_kind,
1002 iri_or_path,
1003 row,
1004 column,
1005 hyperlink_match,
1006 },
1007 )
1008 }
1009
1010 fn line_cells_count(line: &str) -> usize {
1011 // This avoids taking a dependency on the unicode-width crate
1012 fn width(c: char) -> usize {
1013 match c {
1014 // Fullwidth unicode characters used in tests
1015 '例' | '🏃' | '🦀' | '🔥' => 2,
1016 _ => 1,
1017 }
1018 }
1019 const CONTROL_CHARS: &str = "‹«👉👈»›";
1020 line.chars()
1021 .filter(|c| !CONTROL_CHARS.contains(*c))
1022 .map(width)
1023 .sum::<usize>()
1024 }
1025
1026 struct CheckHyperlinkMatch<'a> {
1027 term: &'a Term<VoidListener>,
1028 expected_hyperlink: &'a ExpectedHyperlink,
1029 source_location: &'a str,
1030 }
1031
1032 impl<'a> CheckHyperlinkMatch<'a> {
1033 fn new(
1034 term: &'a Term<VoidListener>,
1035 expected_hyperlink: &'a ExpectedHyperlink,
1036 source_location: &'a str,
1037 ) -> Self {
1038 Self {
1039 term,
1040 expected_hyperlink,
1041 source_location,
1042 }
1043 }
1044
1045 fn check_path_with_position_and_match(
1046 &self,
1047 path_with_position: PathWithPosition,
1048 hyperlink_match: &Match,
1049 ) {
1050 let format_path_with_position_and_match =
1051 |path_with_position: &PathWithPosition, hyperlink_match: &Match| {
1052 let mut result =
1053 format!("Path = «{}»", &path_with_position.path.to_string_lossy());
1054 if let Some(row) = path_with_position.row {
1055 result += &format!(", line = {row}");
1056 if let Some(column) = path_with_position.column {
1057 result += &format!(", column = {column}");
1058 }
1059 }
1060
1061 result += &format!(
1062 ", at grid cells {}",
1063 Self::format_hyperlink_match(hyperlink_match)
1064 );
1065 result
1066 };
1067
1068 assert_ne!(
1069 self.expected_hyperlink.hyperlink_kind,
1070 HyperlinkKind::Iri,
1071 "\n at {}\nExpected a path, but was a iri:\n{}",
1072 self.source_location,
1073 self.format_renderable_content()
1074 );
1075
1076 assert_eq!(
1077 format_path_with_position_and_match(
1078 &PathWithPosition {
1079 path: PathBuf::from(self.expected_hyperlink.iri_or_path.clone()),
1080 row: self.expected_hyperlink.row,
1081 column: self.expected_hyperlink.column
1082 },
1083 &self.expected_hyperlink.hyperlink_match
1084 ),
1085 format_path_with_position_and_match(&path_with_position, hyperlink_match),
1086 "\n at {}:\n{}",
1087 self.source_location,
1088 self.format_renderable_content()
1089 );
1090 }
1091
1092 fn check_iri_and_match(&self, iri: String, hyperlink_match: &Match) {
1093 let format_iri_and_match = |iri: &String, hyperlink_match: &Match| {
1094 format!(
1095 "Url = «{iri}», at grid cells {}",
1096 Self::format_hyperlink_match(hyperlink_match)
1097 )
1098 };
1099
1100 assert_eq!(
1101 self.expected_hyperlink.hyperlink_kind,
1102 HyperlinkKind::Iri,
1103 "\n at {}\nExpected a iri, but was a path:\n{}",
1104 self.source_location,
1105 self.format_renderable_content()
1106 );
1107
1108 assert_eq!(
1109 format_iri_and_match(
1110 &self.expected_hyperlink.iri_or_path,
1111 &self.expected_hyperlink.hyperlink_match
1112 ),
1113 format_iri_and_match(&iri, hyperlink_match),
1114 "\n at {}:\n{}",
1115 self.source_location,
1116 self.format_renderable_content()
1117 );
1118 }
1119
1120 fn format_hyperlink_match(hyperlink_match: &Match) -> String {
1121 format!(
1122 "({}, {})..=({}, {})",
1123 hyperlink_match.start().line.0,
1124 hyperlink_match.start().column.0,
1125 hyperlink_match.end().line.0,
1126 hyperlink_match.end().column.0
1127 )
1128 }
1129
1130 fn format_renderable_content(&self) -> String {
1131 let mut result = format!("\nHovered on '{}'\n", self.expected_hyperlink.hovered_char);
1132
1133 let mut first_header_row = String::new();
1134 let mut second_header_row = String::new();
1135 let mut marker_header_row = String::new();
1136 for index in 0..self.term.columns() {
1137 let remainder = index % 10;
1138 first_header_row.push_str(
1139 &(index > 0 && remainder == 0)
1140 .then_some((index / 10).to_string())
1141 .unwrap_or(" ".into()),
1142 );
1143 second_header_row += &remainder.to_string();
1144 if index == self.expected_hyperlink.hovered_grid_point.column.0 {
1145 marker_header_row.push('↓');
1146 } else {
1147 marker_header_row.push(' ');
1148 }
1149 }
1150
1151 result += &format!("\n [{}]\n", first_header_row);
1152 result += &format!(" [{}]\n", second_header_row);
1153 result += &format!(" {}", marker_header_row);
1154
1155 let spacers: Flags = Flags::LEADING_WIDE_CHAR_SPACER | Flags::WIDE_CHAR_SPACER;
1156 for cell in self
1157 .term
1158 .renderable_content()
1159 .display_iter
1160 .filter(|cell| !cell.flags.intersects(spacers))
1161 {
1162 if cell.point.column.0 == 0 {
1163 let prefix =
1164 if cell.point.line == self.expected_hyperlink.hovered_grid_point.line {
1165 '→'
1166 } else {
1167 ' '
1168 };
1169 result += &format!("\n{prefix}[{:>3}] ", cell.point.line.to_string());
1170 }
1171
1172 result.push(cell.c);
1173 }
1174
1175 result
1176 }
1177 }
1178
1179 fn test_hyperlink<'a>(
1180 columns: usize,
1181 total_cells: usize,
1182 test_lines: impl Iterator<Item = &'a str>,
1183 hyperlink_kind: HyperlinkKind,
1184 source_location: &str,
1185 ) {
1186 thread_local! {
1187 static TEST_REGEX_SEARCHES: RefCell<RegexSearches> = RefCell::new(RegexSearches::new());
1188 }
1189
1190 let term_size = TermSize::new(columns, total_cells / columns + 2);
1191 let (term, expected_hyperlink) =
1192 build_term_from_test_lines(hyperlink_kind, term_size, test_lines);
1193 let hyperlink_found = TEST_REGEX_SEARCHES.with(|regex_searches| {
1194 find_from_grid_point(
1195 &term,
1196 expected_hyperlink.hovered_grid_point,
1197 &mut regex_searches.borrow_mut(),
1198 )
1199 });
1200 let check_hyperlink_match =
1201 CheckHyperlinkMatch::new(&term, &expected_hyperlink, source_location);
1202 match hyperlink_found {
1203 Some((hyperlink_word, false, hyperlink_match)) => {
1204 check_hyperlink_match.check_path_with_position_and_match(
1205 PathWithPosition::parse_str(&hyperlink_word),
1206 &hyperlink_match,
1207 );
1208 }
1209 Some((hyperlink_word, true, hyperlink_match)) => {
1210 check_hyperlink_match.check_iri_and_match(hyperlink_word, &hyperlink_match);
1211 }
1212 _ => {
1213 assert!(
1214 false,
1215 "No hyperlink found\n at {source_location}:\n{}",
1216 check_hyperlink_match.format_renderable_content()
1217 )
1218 }
1219 }
1220 }
1221}