use std::cell::Ref;
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::Widget,
};
#[derive(Debug, Clone)]
pub enum LinkKind {
Url(String),
File { path: std::path::PathBuf, line: Option<usize>, column: Option<usize> },
Diagnostic {
path: Option<std::path::PathBuf>,
line: Option<usize>,
column: Option<usize>,
severity: crate::issue_registry::Severity,
message: String,
},
Search,
SearchCurrent,
}
#[derive(Debug, Clone)]
pub struct Link {
pub kind: LinkKind,
pub row: u16,
pub start_col: u16,
pub end_col: u16,
pub text: String,
}
pub struct TerminalWidget<'a> {
pub parser: Ref<'a, vt100::Parser>,
pub links: Vec<Link>,
pub show_links: bool,
}
impl<'a> Widget for TerminalWidget<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let screen = self.parser.screen();
let draw_rows = area.height;
let cols = area.width;
if draw_rows == 0 || cols == 0 {
return;
}
for row in 0..draw_rows {
for col in 0..cols {
let x = area.left() + col;
let y = area.top() + row;
if let Some(cell_ref) = screen.cell(row, col) {
let contents = cell_ref.contents();
let ch = if contents.is_empty() {
' '
} else {
contents.chars().next().unwrap_or(' ')
};
let mut style = build_style(cell_ref);
for link in &self.links {
if link.row == row
&& col >= link.start_col
&& col < link.end_col
&& let LinkKind::Diagnostic { severity, .. } = &link.kind
{
use crate::issue_registry::Severity;
let bg = match severity {
Severity::Error => Color::Rgb(55, 18, 18),
Severity::Warning => Color::Rgb(50, 40, 8),
_ => Color::Rgb(12, 32, 48),
};
style = style.bg(bg);
}
}
if self.show_links {
for link in &self.links {
if link.row == row && col >= link.start_col && col < link.end_col {
match &link.kind {
LinkKind::Url(_) | LinkKind::File { .. } => {
style = style
.fg(Color::LightBlue)
.add_modifier(Modifier::UNDERLINED);
break;
}
LinkKind::Diagnostic { .. } => {} LinkKind::Search | LinkKind::SearchCurrent => {}
}
}
}
}
for link in &self.links {
if link.row == row && col >= link.start_col && col < link.end_col {
match &link.kind {
LinkKind::SearchCurrent => {
style = style.add_modifier(Modifier::BOLD).bg(Color::Rgb(170, 110, 30)).fg(Color::Black);
}
LinkKind::Search => {
style = style.add_modifier(Modifier::BOLD).bg(Color::Rgb(30, 48, 70));
}
_ => {}
}
}
}
if let Some(buf_cell) = buf.cell_mut((x, y)) {
buf_cell.set_char(ch);
buf_cell.set_style(style);
}
} else if let Some(buf_cell) = buf.cell_mut((x, y)) {
buf_cell.set_char(' ');
buf_cell.set_style(Style::default());
}
}
}
if screen.scrollback() == 0 {
let (crow, ccol) = screen.cursor_position();
if crow < draw_rows && ccol < cols {
let cx = area.left() + ccol;
let cy = area.top() + crow;
if let Some(buf_cell) = buf.cell_mut((cx, cy)) {
let existing = buf_cell.style();
buf_cell.set_style(existing.add_modifier(Modifier::REVERSED));
}
}
}
}
}
fn build_style(cell: &vt100::Cell) -> Style {
let mut style = Style::default()
.fg(map_color(cell.fgcolor()))
.bg(map_color(cell.bgcolor()));
if cell.bold() {
style = style.add_modifier(Modifier::BOLD);
}
if cell.italic() {
style = style.add_modifier(Modifier::ITALIC);
}
if cell.underline() {
style = style.add_modifier(Modifier::UNDERLINED);
}
if cell.inverse() {
style = style.add_modifier(Modifier::REVERSED);
}
style
}
fn map_color(c: vt100::Color) -> Color {
match c {
vt100::Color::Default => Color::Reset,
vt100::Color::Idx(i) => match i {
0 => Color::Black,
1 => Color::Red,
2 => Color::Green,
3 => Color::Yellow,
4 => Color::Blue,
5 => Color::Magenta,
6 => Color::Cyan,
7 => Color::Gray,
8 => Color::DarkGray,
9 => Color::LightRed,
10 => Color::LightGreen,
11 => Color::LightYellow,
12 => Color::LightBlue,
13 => Color::LightMagenta,
14 => Color::LightCyan,
15 => Color::White,
n => Color::Indexed(n),
},
vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b),
}
}
static GLOBAL_FILE_INDEX: once_cell::sync::Lazy<std::sync::Mutex<Option<crate::file_index::SharedFileIndex>>> =
once_cell::sync::Lazy::new(|| std::sync::Mutex::new(None));
static RESOLVE_CACHE: once_cell::sync::Lazy<std::sync::Mutex<std::collections::HashMap<String, (std::time::SystemTime, std::path::PathBuf)>>> =
once_cell::sync::Lazy::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
const RESOLVE_TTL: std::time::Duration = std::time::Duration::from_secs(30);
const RESOLVE_CAP: usize = 4096;
pub(crate) fn set_global_file_index(idx: crate::file_index::SharedFileIndex) {
let mut g = GLOBAL_FILE_INDEX.lock().unwrap();
*g = Some(idx);
}
pub(crate) fn detect_links_from_screen(parser: &vt100::Parser, cwd: &std::path::Path) -> Vec<Link> {
use std::collections::BTreeMap;
const TRIM_CHARS: &[char] = &['(', ')', '[', ']', '{', '}', '.', ',', ';', '"', '\'', '<', '>'];
let mut out: Vec<Link> = Vec::new();
let screen = parser.screen();
let (rows_u16, cols_u16) = screen.size();
let rows = rows_u16 as usize;
let cols = cols_u16 as usize;
let mut searcher = crate::file_index::NucleoSearch::new();
let mut process_chars = |chars: &[char], positions: &[(usize, usize)], cwd: &std::path::Path, out: &mut Vec<Link>| {
if chars.is_empty() {
return;
}
let pairs: Vec<(char, (usize, usize))> = chars.iter().cloned().zip(positions.iter().cloned()).collect();
let filtered_pairs: Vec<(char, (usize, usize))> = pairs.into_iter().filter(|(ch, _)| *ch != '\0').collect();
if filtered_pairs.is_empty() {
return;
}
let mut s = 0usize;
let mut e = filtered_pairs.len();
while s < e && TRIM_CHARS.contains(&filtered_pairs[s].0) {
s += 1;
}
while s < e && TRIM_CHARS.contains(&filtered_pairs[e - 1].0) {
e -= 1;
}
if s >= e {
return;
}
let core_chars: Vec<char> = filtered_pairs[s..e].iter().map(|(c, _)| *c).collect();
let core: String = core_chars.iter().collect();
let core_clean: String = core.chars().filter(|c| !c.is_control()).collect();
let trimmed_positions: Vec<(usize, usize)> = filtered_pairs[s..e].iter().map(|(_, p)| *p).collect();
let mut by_row: BTreeMap<usize, (usize, usize)> = BTreeMap::new();
for &(r, c) in &trimmed_positions {
by_row
.entry(r)
.and_modify(|e| {
if c < e.0 {
e.0 = c;
}
if c > e.1 {
e.1 = c;
}
})
.or_insert((c, c));
}
if core_clean.starts_with("http://") || core_clean.starts_with("https://") {
for (r, (start_c, end_c)) in by_row {
out.push(Link {
kind: LinkKind::Url(core_clean.clone()),
row: r as u16,
start_col: start_c as u16,
end_col: (end_c + 1) as u16,
text: core_clean.clone(),
});
}
return;
}
if core_clean.contains('/') || core_clean.contains('\\') || core_clean.starts_with('.') || core_clean.contains('.') {
let parts: Vec<&str> = core_clean.split(':').collect();
let mut end_i = parts.len();
let mut column = None;
let mut line = None;
while end_i > 0 && parts[end_i - 1].is_empty() {
end_i -= 1;
}
if end_i >= 2 && parts[end_i - 1].chars().all(|c| c.is_ascii_digit()) {
column = parts[end_i - 1].parse::<usize>().ok();
end_i -= 1;
}
if end_i >= 2 && parts[end_i - 1].chars().all(|c| c.is_ascii_digit()) {
line = parts[end_i - 1].parse::<usize>().ok();
end_i -= 1;
}
let base = parts[..end_i].join(":");
let candidate = if std::path::Path::new(&base).is_absolute() {
std::path::PathBuf::from(&base)
} else {
cwd.join(&base)
};
let resolved_path: Option<std::path::PathBuf> = if candidate.is_file() {
std::fs::canonicalize(&candidate).ok().map(|c| {
let s = c.to_string_lossy();
if let Some(stripped) = s.strip_prefix("\\\\?\\") {
std::path::PathBuf::from(stripped)
} else {
c
}
}).or(Some(candidate.clone()))
} else {
let base_path = std::path::Path::new(&base);
let comps: Vec<std::ffi::OsString> = base_path.iter().map(|s| s.to_os_string()).collect();
let mut found: Option<std::path::PathBuf> = None;
for suffix_len in (1..=comps.len()).rev() {
let start = comps.len().saturating_sub(suffix_len);
let mut suffix = std::path::PathBuf::new();
for c in &comps[start..] {
suffix.push(c);
}
let mut matches: Vec<std::path::PathBuf> = Vec::new();
let local_candidate = cwd.join(&suffix);
if local_candidate.is_file() {
matches.push(local_candidate);
}
let cache_key = suffix.to_string_lossy().replace('\\', "/").to_lowercase();
{
let mut cache = RESOLVE_CACHE.lock().unwrap();
if let Some((ts, p)) = cache.get(&cache_key) {
if ts.elapsed().unwrap_or(std::time::Duration::from_secs(u64::MAX)) < RESOLVE_TTL {
matches.push(p.clone());
} else {
cache.remove(&cache_key);
}
}
}
if matches.is_empty()
&& let Some(shared_idx) = GLOBAL_FILE_INDEX.lock().unwrap().as_ref() {
let arc = shared_idx.load();
if let Some(idx) = arc.as_ref() {
let suffix_str = suffix.to_string_lossy().replace('\\', "/").to_lowercase();
let results = searcher.search_top(idx, &suffix_str, 64);
for entry in results {
let entry_str = entry.path.to_string_lossy().replace('\\', "/").to_lowercase();
if entry_str.ends_with(&suffix_str) {
matches.push(cwd.join(&entry.path));
}
}
}
}
if !matches.is_empty() {
matches.sort_by(|a, b| {
let a_rel = a.strip_prefix(cwd).ok().map(|rp| rp.components().count()).unwrap_or(usize::MAX);
let b_rel = b.strip_prefix(cwd).ok().map(|rp| rp.components().count()).unwrap_or(usize::MAX);
if a_rel != b_rel { return a_rel.cmp(&b_rel); }
let a_abs = a.components().count();
let b_abs = b.components().count();
if a_abs != b_abs { return a_abs.cmp(&b_abs); }
a.cmp(b)
});
let chosen = matches.remove(0);
let chosen_canon = std::fs::canonicalize(&chosen).map(|c| {
let s = c.to_string_lossy();
if let Some(stripped) = s.strip_prefix("\\\\?\\") {
std::path::PathBuf::from(stripped)
} else {
c
}
}).unwrap_or(chosen);
found = Some(chosen_canon.clone());
let mut cache = RESOLVE_CACHE.lock().unwrap();
if cache.len() > RESOLVE_CAP {
cache.retain(|_, (t, _)| t.elapsed().unwrap_or(std::time::Duration::from_secs(u64::MAX)) < RESOLVE_TTL);
if cache.len() > RESOLVE_CAP {
let keys: Vec<String> = cache.keys().take(cache.len() / 2).cloned().collect();
for k in keys { cache.remove(&k); }
}
}
cache.insert(cache_key.clone(), (std::time::SystemTime::now(), chosen_canon.clone()));
break;
}
}
found
};
if let Some(resolved) = resolved_path {
for (r, (start_c, end_c)) in by_row {
out.push(Link {
kind: LinkKind::File { path: resolved.clone(), line, column },
row: r as u16,
start_col: start_c as u16,
end_col: (end_c + 1) as u16,
text: core_clean.clone(),
});
}
}
}
};
let mut pending_chars: Vec<char> = Vec::new();
let mut pending_pos: Vec<(usize, usize)> = Vec::new();
for r in 0..rows {
for c in 0..cols {
let r_u16 = r as u16;
let c_u16 = c as u16;
let ch = if let Some(cell_ref) = screen.cell(r_u16, c_u16) {
if cell_ref.is_wide_continuation() {
'\0'
} else if cell_ref.has_contents() {
cell_ref.contents().chars().next().unwrap_or(' ')
} else {
' '
}
} else {
' '
};
if ch.is_whitespace() {
if !pending_chars.is_empty() {
process_chars(&pending_chars, &pending_pos, cwd, &mut out);
pending_chars.clear();
pending_pos.clear();
}
} else {
pending_chars.push(ch);
pending_pos.push((r, c));
}
}
}
if !pending_chars.is_empty() {
process_chars(&pending_chars, &pending_pos, cwd, &mut out);
}
{
use crate::diagnostics_extractor::DiagnosticsExtractor;
static ROW_EXTRACTOR: once_cell::sync::Lazy<DiagnosticsExtractor> =
once_cell::sync::Lazy::new(|| {
DiagnosticsExtractor::new("terminal:visual", "terminal")
});
for r in 0..rows {
let mut row_text = String::with_capacity(cols);
for c in 0..cols {
if let Some(cell) = screen.cell(r as u16, c as u16) {
let contents = cell.contents();
if contents.is_empty() {
row_text.push(' ');
} else {
row_text.push_str(&contents);
}
} else {
row_text.push(' ');
}
}
for issue in ROW_EXTRACTOR.extract_from_str(&row_text) {
out.push(Link {
kind: LinkKind::Diagnostic {
path: issue.path,
line: issue.range.map(|(s, _)| s.line + 1),
column: issue.range.map(|(s, _)| s.column + 1),
severity: issue.severity,
message: issue.message,
},
row: r as u16,
start_col: 0,
end_col: cols as u16,
text: row_text.trim_end().to_string(),
});
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
use vt100::Parser;
#[test]
fn detect_url() {
let mut parser = Parser::new(10, 80, 100);
parser.process(b"http://example.com\n");
let cwd = std::path::Path::new(".");
let links = detect_links_from_screen(&parser, cwd);
assert_eq!(links.len(), 1);
match &links[0].kind {
LinkKind::Url(u) => assert_eq!(u, "http://example.com"),
_ => panic!("expected url link"),
}
}
#[test]
fn detect_wrapped_url_across_two_lines() {
let mut parser = Parser::new(2, 18, 100);
parser.process(b"http://example.com\n");
let cwd = std::path::Path::new(".");
let links = detect_links_from_screen(&parser, cwd);
assert!(links.iter().any(|l| matches!(&l.kind, LinkKind::Url(u) if u == "http://example.com")));
}
#[test]
fn detect_wrapped_file_with_line_col() {
let dir = tempdir().unwrap();
let subdir = dir.path().join("a").join("b").join("c");
std::fs::create_dir_all(&subdir).unwrap();
let pfile = subdir.join("long_filename_example.rs");
std::fs::write(&pfile, "fn main() {}\n").unwrap();
let text = format!("{}:12:3\n", pfile.display());
let visible_len = text.chars().count();
let cols = (visible_len / 2) + 1; let mut parser = Parser::new(3, cols.try_into().unwrap(), 100);
parser.process(text.as_bytes());
let links = detect_links_from_screen(&parser, dir.path());
assert!(links.iter().any(|l| matches!(&l.kind, LinkKind::File { path, line, column } if path == &pfile && line == &Some(12) && column == &Some(3))));
}
#[test]
fn detect_url_with_ansi_wrapped() {
let url = "http://wrapped.example.com";
let visible_len = url.chars().count();
let cols = (visible_len / 2) + 1; let mut parser = Parser::new(3, cols.try_into().unwrap(), 100);
parser.process(format!("\x1b[31m{}\x1b[0m\n", url).as_bytes());
let links = detect_links_from_screen(&parser, std::path::Path::new("."));
assert!(links.iter().any(|l| matches!(&l.kind, LinkKind::Url(u) if u == url)));
}
#[test]
fn detect_file_resolve_non_exact() {
let dir = tempdir().unwrap();
let project = dir.path().join("project");
let src = project.join("src");
std::fs::create_dir_all(&src).unwrap();
let local = src.join("lib.rs");
std::fs::write(&local, "fn main() {}\n").unwrap();
let fake = std::path::Path::new("/tmp/other").join("project").join("src").join("lib.rs");
let text = format!("{}:12:3\n", fake.display());
let mut parser = Parser::new(10, 80, 100);
parser.process(text.as_bytes());
let links = detect_links_from_screen(&parser, project.as_path());
assert!(links.iter().any(|l| matches!(&l.kind, LinkKind::File { path, line, column } if path == &local && line == &Some(12) && column == &Some(3))));
}
#[test]
fn detect_file_with_line_col() {
let dir = tempdir().unwrap();
let pfile = dir.path().join("foo.rs");
std::fs::write(&pfile, "fn main() {}\n").unwrap();
let mut parser = Parser::new(10, 80, 100);
parser.process(b"foo.rs:12:3\n");
let links = detect_links_from_screen(&parser, dir.path());
assert_eq!(links.len(), 1);
match &links[0].kind {
LinkKind::File { path, line, column } => {
assert_eq!(path, &pfile);
assert_eq!(line, &Some(12));
assert_eq!(column, &Some(3));
}
_ => panic!("expected file link"),
}
}
#[test]
fn skip_directory() {
let dir = tempdir().unwrap();
std::fs::create_dir(dir.path().join("somedir")).unwrap();
let mut parser = Parser::new(10, 80, 100);
parser.process(b"./somedir\n");
let links = detect_links_from_screen(&parser, dir.path());
assert!(links.is_empty());
}
#[test]
fn detect_skip_directory_wrapped() {
let dir = tempdir().unwrap();
let deep = dir.path().join("some").join("very").join("long").join("directory");
std::fs::create_dir_all(&deep).unwrap();
let mut parser = Parser::new(2, 12, 100);
parser.process(format!("{}\n", deep.display()).as_bytes());
let links = detect_links_from_screen(&parser, dir.path());
assert!(links.is_empty());
}
#[test]
fn detect_diagnostic_error_row() {
let mut parser = Parser::new(10, 80, 100);
parser.process(b"src/main.rs:42:10: error: type mismatch\n");
let links = detect_links_from_screen(&parser, std::path::Path::new("."));
let diag = links.iter().find(|l| matches!(&l.kind, LinkKind::Diagnostic { .. }));
assert!(diag.is_some(), "expected a Diagnostic link for error row");
if let LinkKind::Diagnostic { severity, message, .. } = &diag.unwrap().kind {
assert_eq!(*severity, crate::issue_registry::Severity::Error);
assert!(message.contains("type mismatch"), "msg: {message}");
}
}
#[test]
fn detect_diagnostic_warning_row() {
let mut parser = Parser::new(10, 80, 100);
parser.process(b"lib/foo.rs:10:5: warning: unused variable\n");
let links = detect_links_from_screen(&parser, std::path::Path::new("."));
let diag = links.iter().find(|l| matches!(
&l.kind,
LinkKind::Diagnostic { severity, .. } if *severity == crate::issue_registry::Severity::Warning
));
assert!(diag.is_some(), "expected a Warning Diagnostic link");
}
#[test]
fn detect_diagnostic_does_not_fire_on_plain_output() {
let mut parser = Parser::new(10, 80, 100);
parser.process(b" Compiling mylib v0.1.0\n");
let links = detect_links_from_screen(&parser, std::path::Path::new("."));
let has_diag = links.iter().any(|l| matches!(&l.kind, LinkKind::Diagnostic { .. }));
assert!(!has_diag, "plain compile lines should not produce Diagnostic links");
}
#[test]
fn diagnostic_and_file_links_coexist_on_same_row() {
let dir = tempdir().unwrap();
let pfile = dir.path().join("foo.rs");
std::fs::write(&pfile, "fn main() {}\n").unwrap();
let text = format!("{}:3:1: error: undeclared variable\n", pfile.display());
let mut parser = Parser::new(10, 120, 100);
parser.process(text.as_bytes());
let links = detect_links_from_screen(&parser, dir.path());
let has_file = links.iter().any(|l| matches!(&l.kind, LinkKind::File { .. }));
let has_diag = links.iter().any(|l| matches!(&l.kind, LinkKind::Diagnostic { .. }));
assert!(has_file, "expected a File link for the path token");
assert!(has_diag, "expected a Diagnostic link for the row background");
}
}