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