1use std::cell::Ref;
8
9use ratatui::{
10 buffer::Buffer,
11 layout::Rect,
12 style::{Color, Modifier, Style},
13 widgets::Widget,
14};
15
16#[derive(Debug, Clone)]
17pub enum LinkKind {
18 Url(String),
19 File { path: std::path::PathBuf, line: Option<usize>, column: Option<usize> },
20 Diagnostic {
24 path: Option<std::path::PathBuf>,
25 line: Option<usize>,
26 column: Option<usize>,
27 severity: crate::issue_registry::Severity,
28 message: String,
29 },
30 Search,
32 SearchCurrent,
35}
36
37#[derive(Debug, Clone)]
38pub struct Link {
39 pub kind: LinkKind,
40 pub row: u16,
41 pub start_col: u16,
42 pub end_col: u16,
43 pub text: String,
44}
45
46pub struct TerminalWidget<'a> {
47 pub parser: Ref<'a, vt100::Parser>,
48 pub links: Vec<Link>,
49 pub show_links: bool,
52}
53
54impl<'a> Widget for TerminalWidget<'a> {
55 fn render(self, area: Rect, buf: &mut Buffer) {
56 if area.height == 0 || area.width == 0 {
57 return;
58 }
59
60 let screen = self.parser.screen();
61 let draw_rows = area.height;
65 let cols = area.width;
66
67 if draw_rows == 0 || cols == 0 {
68 return;
69 }
70
71 for row in 0..draw_rows {
73 for col in 0..cols {
74 let x = area.left() + col;
75 let y = area.top() + row;
76
77 if let Some(cell_ref) = screen.cell(row, col) {
78 let contents = cell_ref.contents();
79 let ch = if contents.is_empty() {
80 ' '
81 } else {
82 contents.chars().next().unwrap_or(' ')
83 };
84 let mut style = build_style(cell_ref);
85
86 for link in &self.links {
91 if link.row == row
92 && col >= link.start_col
93 && col < link.end_col
94 && let LinkKind::Diagnostic { severity, .. } = &link.kind
95 {
96 use crate::issue_registry::Severity;
97 let bg = match severity {
98 Severity::Error => Color::Rgb(55, 18, 18),
99 Severity::Warning => Color::Rgb(50, 40, 8),
100 _ => Color::Rgb(12, 32, 48),
101 };
102 style = style.bg(bg);
103 }
104 }
105 if self.show_links {
106 for link in &self.links {
107 if link.row == row && col >= link.start_col && col < link.end_col {
108 match &link.kind {
109 LinkKind::Url(_) | LinkKind::File { .. } => {
110 style = style
111 .fg(Color::LightBlue)
112 .add_modifier(Modifier::UNDERLINED);
113 break;
114 }
115 LinkKind::Diagnostic { .. } => {} LinkKind::Search | LinkKind::SearchCurrent => {}
117 }
118 }
119 }
120 }
121
122 for link in &self.links {
127 if link.row == row && col >= link.start_col && col < link.end_col {
128 match &link.kind {
129 LinkKind::SearchCurrent => {
130 style = style.add_modifier(Modifier::BOLD).bg(Color::Rgb(170, 110, 30)).fg(Color::Black);
131 }
132 LinkKind::Search => {
133 style = style.add_modifier(Modifier::BOLD).bg(Color::Rgb(30, 48, 70));
134 }
135 _ => {}
136 }
137 }
138 }
139
140 if let Some(buf_cell) = buf.cell_mut((x, y)) {
141 buf_cell.set_char(ch);
142 buf_cell.set_style(style);
143 }
144 } else if let Some(buf_cell) = buf.cell_mut((x, y)) {
145 buf_cell.set_char(' ');
146 buf_cell.set_style(Style::default());
147 }
148 }
149 }
150
151 if screen.scrollback() == 0 {
153 let (crow, ccol) = screen.cursor_position();
154 if crow < draw_rows && ccol < cols {
155 let cx = area.left() + ccol;
156 let cy = area.top() + crow;
157 if let Some(buf_cell) = buf.cell_mut((cx, cy)) {
158 let existing = buf_cell.style();
159 buf_cell.set_style(existing.add_modifier(Modifier::REVERSED));
160 }
161 }
162 }
163 }
164}
165
166fn build_style(cell: &vt100::Cell) -> Style {
167 let mut style = Style::default()
168 .fg(map_color(cell.fgcolor()))
169 .bg(map_color(cell.bgcolor()));
170
171 if cell.bold() {
172 style = style.add_modifier(Modifier::BOLD);
173 }
174 if cell.italic() {
175 style = style.add_modifier(Modifier::ITALIC);
176 }
177 if cell.underline() {
178 style = style.add_modifier(Modifier::UNDERLINED);
179 }
180 if cell.inverse() {
181 style = style.add_modifier(Modifier::REVERSED);
182 }
183
184 style
185}
186
187fn map_color(c: vt100::Color) -> Color {
188 match c {
189 vt100::Color::Default => Color::Reset,
190 vt100::Color::Idx(i) => match i {
191 0 => Color::Black,
192 1 => Color::Red,
193 2 => Color::Green,
194 3 => Color::Yellow,
195 4 => Color::Blue,
196 5 => Color::Magenta,
197 6 => Color::Cyan,
198 7 => Color::Gray,
199 8 => Color::DarkGray,
200 9 => Color::LightRed,
201 10 => Color::LightGreen,
202 11 => Color::LightYellow,
203 12 => Color::LightBlue,
204 13 => Color::LightMagenta,
205 14 => Color::LightCyan,
206 15 => Color::White,
207 n => Color::Indexed(n),
208 },
209 vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b),
210 }
211}
212
213static GLOBAL_REGISTRY: once_cell::sync::Lazy<std::sync::Mutex<Option<crate::file_index::SharedRegistry>>> =
219 once_cell::sync::Lazy::new(|| std::sync::Mutex::new(None));
220
221static RESOLVE_CACHE: once_cell::sync::Lazy<std::sync::Mutex<std::collections::HashMap<String, (std::time::SystemTime, std::path::PathBuf)>>> =
224 once_cell::sync::Lazy::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
225
226const RESOLVE_TTL: std::time::Duration = std::time::Duration::from_secs(30);
227const RESOLVE_CAP: usize = 4096;
228
229pub(crate) fn set_global_registry(idx: crate::file_index::SharedRegistry) {
230 let mut g = GLOBAL_REGISTRY.lock().unwrap();
231 *g = Some(idx);
232}
233
234pub(crate) fn detect_links_from_screen(parser: &vt100::Parser, cwd: &std::path::Path) -> Vec<Link> {
235 use std::collections::BTreeMap;
236
237 const TRIM_CHARS: &[char] = &['(', ')', '[', ']', '{', '}', '.', ',', ';', '"', '\'', '<', '>'];
238
239 let mut out: Vec<Link> = Vec::new();
240 let screen = parser.screen();
241 let (rows_u16, cols_u16) = screen.size();
242 let rows = rows_u16 as usize;
243 let cols = cols_u16 as usize;
244
245
246 let mut searcher = crate::file_index::NucleoSearch::new();
247
248
249 let mut process_chars = |chars: &[char], positions: &[(usize, usize)], cwd: &std::path::Path, out: &mut Vec<Link>| {
250 if chars.is_empty() {
251 return;
252 }
253 let pairs: Vec<(char, (usize, usize))> = chars.iter().cloned().zip(positions.iter().cloned()).collect();
257 let filtered_pairs: Vec<(char, (usize, usize))> = pairs.into_iter().filter(|(ch, _)| *ch != '\0').collect();
258 if filtered_pairs.is_empty() {
259 return;
260 }
261
262 let mut s = 0usize;
264 let mut e = filtered_pairs.len();
265 while s < e && TRIM_CHARS.contains(&filtered_pairs[s].0) {
266 s += 1;
267 }
268 while s < e && TRIM_CHARS.contains(&filtered_pairs[e - 1].0) {
269 e -= 1;
270 }
271 if s >= e {
272 return;
273 }
274
275 let core_chars: Vec<char> = filtered_pairs[s..e].iter().map(|(c, _)| *c).collect();
276 let core: String = core_chars.iter().collect();
277 let core_clean: String = core.chars().filter(|c| !c.is_control()).collect();
278 let trimmed_positions: Vec<(usize, usize)> = filtered_pairs[s..e].iter().map(|(_, p)| *p).collect();
279
280 let mut by_row: BTreeMap<usize, (usize, usize)> = BTreeMap::new();
282 for &(r, c) in &trimmed_positions {
283 by_row
284 .entry(r)
285 .and_modify(|e| {
286 if c < e.0 {
287 e.0 = c;
288 }
289 if c > e.1 {
290 e.1 = c;
291 }
292 })
293 .or_insert((c, c));
294 }
295
296 if core_clean.starts_with("http://") || core_clean.starts_with("https://") {
297 for (r, (start_c, end_c)) in by_row {
298 out.push(Link {
299 kind: LinkKind::Url(core_clean.clone()),
300 row: r as u16,
301 start_col: start_c as u16,
302 end_col: (end_c + 1) as u16,
303 text: core_clean.clone(),
304 });
305 }
306 return;
307 }
308
309 if core_clean.contains('/') || core_clean.contains('\\') || core_clean.starts_with('.') || core_clean.contains('.') {
310 let parts: Vec<&str> = core_clean.split(':').collect();
311 let mut end_i = parts.len();
312 let mut column = None;
313 let mut line = None;
314 while end_i > 0 && parts[end_i - 1].is_empty() {
316 end_i -= 1;
317 }
318 if end_i >= 2 && parts[end_i - 1].chars().all(|c| c.is_ascii_digit()) {
319 column = parts[end_i - 1].parse::<usize>().ok();
320 end_i -= 1;
321 }
322 if end_i >= 2 && parts[end_i - 1].chars().all(|c| c.is_ascii_digit()) {
323 line = parts[end_i - 1].parse::<usize>().ok();
324 end_i -= 1;
325 }
326 let base = parts[..end_i].join(":");
327 let candidate = if std::path::Path::new(&base).is_absolute() {
328 std::path::PathBuf::from(&base)
329 } else {
330 cwd.join(&base)
331 };
332
333 let resolved_path: Option<std::path::PathBuf> = if candidate.is_file() {
337 std::fs::canonicalize(&candidate).ok().map(|c| {
341 let s = c.to_string_lossy();
342 if let Some(stripped) = s.strip_prefix("\\\\?\\") {
343 std::path::PathBuf::from(stripped)
344 } else {
345 c
346 }
347 }).or(Some(candidate.clone()))
348 } else {
349 let base_path = std::path::Path::new(&base);
350 let comps: Vec<std::ffi::OsString> = base_path.iter().map(|s| s.to_os_string()).collect();
351 let mut found: Option<std::path::PathBuf> = None;
352 for suffix_len in (1..=comps.len()).rev() {
354 let start = comps.len().saturating_sub(suffix_len);
355 let mut suffix = std::path::PathBuf::new();
356 for c in &comps[start..] {
357 suffix.push(c);
358 }
359 let mut matches: Vec<std::path::PathBuf> = Vec::new();
360 let local_candidate = cwd.join(&suffix);
362 if local_candidate.is_file() {
363 matches.push(local_candidate);
364 }
365 let cache_key = suffix.to_string_lossy().replace('\\', "/").to_lowercase();
367 {
368 let mut cache = RESOLVE_CACHE.lock().unwrap();
369 if let Some((ts, p)) = cache.get(&cache_key) {
370 if ts.elapsed().unwrap_or(std::time::Duration::from_secs(u64::MAX)) < RESOLVE_TTL {
371 matches.push(p.clone());
372 } else {
373 cache.remove(&cache_key);
374 }
375 }
376 }
377 if matches.is_empty()
379 && let Some(shared_idx) = GLOBAL_REGISTRY.lock().unwrap().as_ref() {
380 let arc = shared_idx.load();
381 if let Some(reg) = (**arc).as_ref() {
382 let suffix_str = suffix.to_string_lossy().replace('\\', "/").to_lowercase();
384 let results = searcher.search_top(reg.file_index(), &suffix_str, 64);
386 for entry in results {
387 let entry_str = entry.path.to_string_lossy().replace('\\', "/").to_lowercase();
388 if entry_str.ends_with(&suffix_str) {
389 matches.push(cwd.join(&entry.path));
390 }
391 }
392 }
393 }
394
395 if !matches.is_empty() {
398 matches.sort_by(|a, b| {
401 let a_rel = a.strip_prefix(cwd).ok().map(|rp| rp.components().count()).unwrap_or(usize::MAX);
402 let b_rel = b.strip_prefix(cwd).ok().map(|rp| rp.components().count()).unwrap_or(usize::MAX);
403 if a_rel != b_rel { return a_rel.cmp(&b_rel); }
404 let a_abs = a.components().count();
405 let b_abs = b.components().count();
406 if a_abs != b_abs { return a_abs.cmp(&b_abs); }
407 a.cmp(b)
409 });
410 let chosen = matches.remove(0);
412 let chosen_canon = std::fs::canonicalize(&chosen).map(|c| {
413 let s = c.to_string_lossy();
414 if let Some(stripped) = s.strip_prefix("\\\\?\\") {
415 std::path::PathBuf::from(stripped)
416 } else {
417 c
418 }
419 }).unwrap_or(chosen);
420 found = Some(chosen_canon.clone());
421 let mut cache = RESOLVE_CACHE.lock().unwrap();
423 if cache.len() > RESOLVE_CAP {
424 cache.retain(|_, (t, _)| t.elapsed().unwrap_or(std::time::Duration::from_secs(u64::MAX)) < RESOLVE_TTL);
426 if cache.len() > RESOLVE_CAP {
427 let keys: Vec<String> = cache.keys().take(cache.len() / 2).cloned().collect();
429 for k in keys { cache.remove(&k); }
430 }
431 }
432 cache.insert(cache_key.clone(), (std::time::SystemTime::now(), chosen_canon.clone()));
433 break;
434 }
435 }
436 found
437 };
438
439 if let Some(resolved) = resolved_path {
440 for (r, (start_c, end_c)) in by_row {
441 out.push(Link {
442 kind: LinkKind::File { path: resolved.clone(), line, column },
443 row: r as u16,
444 start_col: start_c as u16,
445 end_col: (end_c + 1) as u16,
446 text: core_clean.clone(),
447 });
448 }
449 }
450 }
451 };
452
453 let mut pending_chars: Vec<char> = Vec::new();
454 let mut pending_pos: Vec<(usize, usize)> = Vec::new();
455
456 for r in 0..rows {
457 for c in 0..cols {
458 let r_u16 = r as u16;
459 let c_u16 = c as u16;
460 let ch = if let Some(cell_ref) = screen.cell(r_u16, c_u16) {
461 if cell_ref.is_wide_continuation() {
464 '\0'
465 } else if cell_ref.has_contents() {
466 cell_ref.contents().chars().next().unwrap_or(' ')
467 } else {
468 ' '
470 }
471 } else {
472 ' '
473 };
474
475 if ch.is_whitespace() {
477 if !pending_chars.is_empty() {
478 process_chars(&pending_chars, &pending_pos, cwd, &mut out);
479 pending_chars.clear();
480 pending_pos.clear();
481 }
482 } else {
483 pending_chars.push(ch);
484 pending_pos.push((r, c));
485 }
486 }
487 }
488
489 if !pending_chars.is_empty() {
490 process_chars(&pending_chars, &pending_pos, cwd, &mut out);
491 }
492
493 {
497 use crate::diagnostics_extractor::DiagnosticsExtractor;
498 static ROW_EXTRACTOR: once_cell::sync::Lazy<DiagnosticsExtractor> =
499 once_cell::sync::Lazy::new(|| {
500 DiagnosticsExtractor::new("terminal:visual", "terminal")
501 });
502 for r in 0..rows {
503 let mut row_text = String::with_capacity(cols);
504 for c in 0..cols {
505 if let Some(cell) = screen.cell(r as u16, c as u16) {
506 let contents = cell.contents();
507 if contents.is_empty() {
508 row_text.push(' ');
509 } else {
510 row_text.push_str(contents);
511 }
512 } else {
513 row_text.push(' ');
514 }
515 }
516 for issue in ROW_EXTRACTOR.extract_from_str(&row_text) {
517 out.push(Link {
518 kind: LinkKind::Diagnostic {
519 path: issue.path,
520 line: issue.range.map(|(s, _)| s.line + 1),
521 column: issue.range.map(|(s, _)| s.column + 1),
522 severity: issue.severity,
523 message: issue.message,
524 },
525 row: r as u16,
526 start_col: 0,
527 end_col: cols as u16,
528 text: row_text.trim_end().to_string(),
529 });
530 }
531 }
532 }
533
534 out
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540 use tempfile::tempdir;
541 use vt100::Parser;
542
543 #[test]
544 fn detect_url() {
545 let mut parser = Parser::new(10, 80, 100);
546 parser.process(b"http://example.com\n");
547 let cwd = std::path::Path::new(".");
548 let links = detect_links_from_screen(&parser, cwd);
549 assert_eq!(links.len(), 1);
550 match &links[0].kind {
551 LinkKind::Url(u) => assert_eq!(u, "http://example.com"),
552 _ => panic!("expected url link"),
553 }
554 }
555
556 #[test]
557 fn detect_wrapped_url_across_two_lines() {
558 let mut parser = Parser::new(2, 18, 100);
560 parser.process(b"http://example.com\n");
561 let cwd = std::path::Path::new(".");
562 let links = detect_links_from_screen(&parser, cwd);
563 assert!(links.iter().any(|l| matches!(&l.kind, LinkKind::Url(u) if u == "http://example.com")));
564 }
565
566 #[test]
567 fn detect_wrapped_file_with_line_col() {
568 let dir = tempdir().unwrap();
571 let subdir = dir.path().join("a").join("b").join("c");
572 std::fs::create_dir_all(&subdir).unwrap();
573 let pfile = subdir.join("long_filename_example.rs");
574 std::fs::write(&pfile, "fn main() {}\n").unwrap();
575
576 let text = format!("{}:12:3\n", pfile.display());
580 let visible_len = text.chars().count();
581 let cols = (visible_len / 2) + 1; let mut parser = Parser::new(3, cols.try_into().unwrap(), 100);
583 parser.process(text.as_bytes());
584
585 let links = detect_links_from_screen(&parser, dir.path());
586 assert!(links.iter().any(|l| matches!(&l.kind, LinkKind::File { path, line, column } if path == &pfile && line == &Some(12) && column == &Some(3))));
587 }
588
589 #[test]
590 fn detect_url_with_ansi_wrapped() {
591 let url = "http://wrapped.example.com";
595 let visible_len = url.chars().count();
596 let cols = (visible_len / 2) + 1; let mut parser = Parser::new(3, cols.try_into().unwrap(), 100);
598 parser.process(format!("\x1b[31m{}\x1b[0m\n", url).as_bytes());
599
600 let links = detect_links_from_screen(&parser, std::path::Path::new("."));
601 assert!(links.iter().any(|l| matches!(&l.kind, LinkKind::Url(u) if u == url)));
602 }
603
604 #[test]
605 fn detect_file_resolve_non_exact() {
606 let dir = tempdir().unwrap();
610 let project = dir.path().join("project");
611 let src = project.join("src");
612 std::fs::create_dir_all(&src).unwrap();
613 let local = src.join("lib.rs");
614 std::fs::write(&local, "fn main() {}\n").unwrap();
615
616 let fake = std::path::Path::new("/tmp/other").join("project").join("src").join("lib.rs");
618 let text = format!("{}:12:3\n", fake.display());
619
620 let mut parser = Parser::new(10, 80, 100);
621 parser.process(text.as_bytes());
622
623 let links = detect_links_from_screen(&parser, project.as_path());
624 assert!(links.iter().any(|l| matches!(&l.kind, LinkKind::File { path, line, column } if path == &local && line == &Some(12) && column == &Some(3))));
625 }
626
627 #[test]
628 fn detect_file_with_line_col() {
629 let dir = tempdir().unwrap();
630 let pfile = dir.path().join("foo.rs");
631 std::fs::write(&pfile, "fn main() {}\n").unwrap();
632 let mut parser = Parser::new(10, 80, 100);
633 parser.process(b"foo.rs:12:3\n");
634 let links = detect_links_from_screen(&parser, dir.path());
635 assert_eq!(links.len(), 1);
636 match &links[0].kind {
637 LinkKind::File { path, line, column } => {
638 assert_eq!(path, &pfile);
639 assert_eq!(line, &Some(12));
640 assert_eq!(column, &Some(3));
641 }
642 _ => panic!("expected file link"),
643 }
644 }
645
646 #[test]
647 fn skip_directory() {
648 let dir = tempdir().unwrap();
649 std::fs::create_dir(dir.path().join("somedir")).unwrap();
650 let mut parser = Parser::new(10, 80, 100);
651 parser.process(b"./somedir\n");
652 let links = detect_links_from_screen(&parser, dir.path());
653 assert!(links.is_empty());
654 }
655
656 #[test]
657 fn detect_skip_directory_wrapped() {
658 let dir = tempdir().unwrap();
659 let deep = dir.path().join("some").join("very").join("long").join("directory");
660 std::fs::create_dir_all(&deep).unwrap();
661 let mut parser = Parser::new(2, 12, 100);
662 parser.process(format!("{}\n", deep.display()).as_bytes());
663 let links = detect_links_from_screen(&parser, dir.path());
664 assert!(links.is_empty());
665 }
666
667 #[test]
670 fn detect_diagnostic_error_row() {
671 let mut parser = Parser::new(10, 80, 100);
673 parser.process(b"src/main.rs:42:10: error: type mismatch\n");
674 let links = detect_links_from_screen(&parser, std::path::Path::new("."));
675 let diag = links.iter().find(|l| matches!(&l.kind, LinkKind::Diagnostic { .. }));
676 assert!(diag.is_some(), "expected a Diagnostic link for error row");
677 if let LinkKind::Diagnostic { severity, message, .. } = &diag.unwrap().kind {
678 assert_eq!(*severity, crate::issue_registry::Severity::Error);
679 assert!(message.contains("type mismatch"), "msg: {message}");
680 }
681 }
682
683 #[test]
684 fn detect_diagnostic_warning_row() {
685 let mut parser = Parser::new(10, 80, 100);
686 parser.process(b"lib/foo.rs:10:5: warning: unused variable\n");
687 let links = detect_links_from_screen(&parser, std::path::Path::new("."));
688 let diag = links.iter().find(|l| matches!(
689 &l.kind,
690 LinkKind::Diagnostic { severity, .. } if *severity == crate::issue_registry::Severity::Warning
691 ));
692 assert!(diag.is_some(), "expected a Warning Diagnostic link");
693 }
694
695 #[test]
696 fn detect_diagnostic_does_not_fire_on_plain_output() {
697 let mut parser = Parser::new(10, 80, 100);
698 parser.process(b" Compiling mylib v0.1.0\n");
699 let links = detect_links_from_screen(&parser, std::path::Path::new("."));
700 let has_diag = links.iter().any(|l| matches!(&l.kind, LinkKind::Diagnostic { .. }));
701 assert!(!has_diag, "plain compile lines should not produce Diagnostic links");
702 }
703
704 #[test]
705 fn diagnostic_and_file_links_coexist_on_same_row() {
706 let dir = tempdir().unwrap();
709 let pfile = dir.path().join("foo.rs");
710 std::fs::write(&pfile, "fn main() {}\n").unwrap();
711 let text = format!("{}:3:1: error: undeclared variable\n", pfile.display());
712 let mut parser = Parser::new(10, 120, 100);
713 parser.process(text.as_bytes());
714 let links = detect_links_from_screen(&parser, dir.path());
715 let has_file = links.iter().any(|l| matches!(&l.kind, LinkKind::File { .. }));
716 let has_diag = links.iter().any(|l| matches!(&l.kind, LinkKind::Diagnostic { .. }));
717 assert!(has_file, "expected a File link for the path token");
718 assert!(has_diag, "expected a Diagnostic link for the row background");
719 }
720}