use std::io::Write;
use std::sync::LazyLock;
use crossterm::cursor::MoveTo;
use crossterm::queue;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use regex::Regex;
use crate::widgets::chat::MdLink;
static URL_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"https?://[^\s<>\[\]()\x22'`]+").unwrap());
#[derive(Debug)]
pub struct HyperlinkSpan {
pub url: String,
pub row: u16,
pub start_col: u16,
pub end_col: u16,
}
pub fn detect_urls_in_text(text: &str) -> Vec<(std::ops::Range<usize>, String)> {
URL_RE
.find_iter(text)
.map(|m| (m.start()..m.end(), m.as_str().to_string()))
.collect()
}
#[must_use]
pub fn collect_from_buffer(buffer: &Buffer, area: Rect) -> Vec<HyperlinkSpan> {
let mut spans = Vec::new();
for row in area.y..area.y + area.height {
let mut row_text = String::new();
let mut col_offsets: Vec<u16> = Vec::new();
for col in area.x..area.x + area.width {
let sym = buffer[(col, row)].symbol();
for _ in sym.chars() {
col_offsets.push(col);
}
row_text.push_str(sym);
}
for (range, url) in detect_urls_in_text(&row_text) {
let Some(&start_col) = col_offsets.get(range.start) else {
continue;
};
let end_col = col_offsets
.get(range.end.saturating_sub(1))
.map_or(start_col + 1, |c| c + 1);
spans.push(HyperlinkSpan {
url,
row,
start_col,
end_col,
});
}
}
spans
}
fn is_safe_url(url: &str) -> bool {
url.starts_with("https://") || url.starts_with("http://")
}
#[must_use]
pub fn collect_from_buffer_with_md_links(
buffer: &Buffer,
area: Rect,
md_links: &[MdLink],
) -> Vec<HyperlinkSpan> {
let safe_links: Vec<&MdLink> = md_links
.iter()
.filter(|l| !l.text.is_empty() && is_safe_url(&l.url))
.collect();
let mut spans: Vec<HyperlinkSpan> = Vec::new();
for row in area.y..area.y + area.height {
let mut row_chars: Vec<char> = Vec::new();
let mut col_offsets: Vec<u16> = Vec::new();
for col in area.x..area.x + area.width {
let sym = buffer[(col, row)].symbol();
for ch in sym.chars() {
col_offsets.push(col);
row_chars.push(ch);
}
}
let row_text: String = row_chars.iter().collect();
let mut row_spans: Vec<HyperlinkSpan> = Vec::new();
for (range, url) in detect_urls_in_text(&row_text) {
let Some(&start_col) = col_offsets.get(range.start) else {
continue;
};
let end_col = col_offsets
.get(range.end.saturating_sub(1))
.map_or(start_col + 1, |c| c + 1);
row_spans.push(HyperlinkSpan {
url,
row,
start_col,
end_col,
});
}
for link in &safe_links {
let link_chars: Vec<char> = link.text.chars().collect();
let link_len = link_chars.len();
if link_len == 0 || link_len > row_chars.len() {
continue;
}
let mut search_from = 0;
while search_from + link_len <= row_chars.len() {
if row_chars[search_from..search_from + link_len] == link_chars[..] {
let start_col = col_offsets[search_from];
let end_col = col_offsets[search_from + link_len - 1] + 1;
row_spans.retain(|s| s.end_col <= start_col || s.start_col >= end_col);
row_spans.push(HyperlinkSpan {
url: link.url.clone(),
row,
start_col,
end_col,
});
search_from += link_len;
} else {
search_from += 1;
}
}
}
spans.extend(row_spans);
}
spans
}
pub fn write_osc8(writer: &mut impl Write, spans: &[HyperlinkSpan]) -> std::io::Result<()> {
for span in spans {
let safe_url: String = span.url.chars().filter(|c| !c.is_ascii_control()).collect();
queue!(writer, MoveTo(span.start_col, span.row))?;
write!(writer, "\x1b]8;;{safe_url}\x1b\\")?;
queue!(writer, MoveTo(span.end_col, span.row))?;
write!(writer, "\x1b]8;;\x1b\\")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_urls_basic() {
let urls = detect_urls_in_text("visit https://example.com for info");
assert_eq!(urls.len(), 1);
assert_eq!(urls[0].1, "https://example.com");
}
#[test]
fn detect_urls_multiple() {
let text = "see http://a.com and https://b.org/path?q=1";
let urls = detect_urls_in_text(text);
assert_eq!(urls.len(), 2);
assert_eq!(urls[0].1, "http://a.com");
assert_eq!(urls[1].1, "https://b.org/path?q=1");
}
#[test]
fn detect_urls_none() {
let urls = detect_urls_in_text("no links here");
assert!(urls.is_empty());
}
#[test]
fn detect_urls_in_markdown_brackets() {
let urls = detect_urls_in_text("[text](https://example.com)");
assert_eq!(urls.len(), 1);
assert_eq!(urls[0].1, "https://example.com");
}
#[test]
fn collect_from_buffer_finds_urls() {
let area = Rect::new(0, 0, 40, 2);
let mut buf = Buffer::empty(area);
buf.set_string(
0,
0,
"visit https://example.com now",
ratatui::style::Style::default(),
);
buf.set_string(0, 1, "no links here", ratatui::style::Style::default());
let spans = collect_from_buffer(&buf, area);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].url, "https://example.com");
assert_eq!(spans[0].row, 0);
assert_eq!(spans[0].start_col, 6);
assert_eq!(spans[0].end_col, 25);
}
#[test]
fn collect_with_md_links_adds_link_span() {
let area = Rect::new(0, 0, 40, 1);
let mut buf = Buffer::empty(area);
buf.set_string(
0,
0,
"click here for info",
ratatui::style::Style::default(),
);
let md_links = vec![MdLink {
text: "click here".to_string(),
url: "https://example.com".to_string(),
}];
let spans = collect_from_buffer_with_md_links(&buf, area, &md_links);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].url, "https://example.com");
assert_eq!(spans[0].start_col, 0);
assert_eq!(spans[0].end_col, 10);
}
#[test]
fn collect_with_md_links_replaces_bare_url_overlap() {
let area = Rect::new(0, 0, 50, 1);
let mut buf = Buffer::empty(area);
buf.set_string(
0,
0,
"https://example.com",
ratatui::style::Style::default(),
);
let md_links = vec![MdLink {
text: "https://example.com".to_string(),
url: "https://example.com".to_string(),
}];
let spans = collect_from_buffer_with_md_links(&buf, area, &md_links);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].url, "https://example.com");
}
#[test]
fn collect_with_md_links_non_ascii_text() {
let area = Rect::new(0, 0, 10, 1);
let mut buf = Buffer::empty(area);
buf.set_string(0, 0, "日本語", ratatui::style::Style::default());
let mut row_chars: Vec<char> = Vec::new();
for col in 0u16..10 {
let sym = buf[(col, 0)].symbol();
for ch in sym.chars() {
row_chars.push(ch);
}
}
let row_text: String = row_chars.iter().collect();
let md_links = vec![MdLink {
text: "日本語".to_string(),
url: "https://example.com".to_string(),
}];
let spans = collect_from_buffer_with_md_links(&buf, area, &md_links);
if row_text.contains("日本語") {
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].url, "https://example.com");
} else {
assert_eq!(spans.len(), 0);
}
}
#[test]
fn collect_with_md_links_rejects_unsafe_scheme() {
let area = Rect::new(0, 0, 30, 1);
let mut buf = Buffer::empty(area);
buf.set_string(0, 0, "click me", ratatui::style::Style::default());
let md_links = vec![MdLink {
text: "click me".to_string(),
url: "javascript:alert(1)".to_string(),
}];
let spans = collect_from_buffer_with_md_links(&buf, area, &md_links);
assert!(spans.is_empty());
}
#[test]
fn write_osc8_strips_control_chars() {
let spans = vec![HyperlinkSpan {
url: "https://x.com/\x1b]evil".to_string(),
row: 0,
start_col: 0,
end_col: 5,
}];
let mut buf = Vec::new();
write_osc8(&mut buf, &spans).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("https://x.com/]evil"));
assert!(!output.contains("https://x.com/\x1b]evil"));
}
#[test]
fn write_osc8_produces_escape_sequences() {
let spans = vec![HyperlinkSpan {
url: "https://x.com".to_string(),
row: 0,
start_col: 0,
end_col: 5,
}];
let mut buf = Vec::new();
write_osc8(&mut buf, &spans).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("\x1b]8;;https://x.com\x1b\\"));
assert!(output.contains("\x1b]8;;\x1b\\"));
}
mod proptest_hyperlink {
use super::*;
use proptest::prelude::*;
fn ascii_text() -> impl Strategy<Value = String> {
"[a-zA-Z0-9 ]{1,60}"
}
fn safe_url() -> impl Strategy<Value = String> {
"[a-zA-Z0-9/._~-]{1,40}".prop_map(|s| format!("https://example.com/{s}"))
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(200))]
#[test]
fn collect_never_panics(
text in ascii_text(),
url in safe_url(),
width in 20u16..120,
) {
let area = Rect::new(0, 0, width, 3);
let mut buf = Buffer::empty(area);
buf.set_string(0, 0, &text, ratatui::style::Style::default());
let md_links = vec![MdLink {
text: text.clone(),
url,
}];
let _ = collect_from_buffer_with_md_links(&buf, area, &md_links);
}
#[test]
fn spans_within_buffer_bounds(
text in "[a-z]{3,20}",
url in safe_url(),
width in 30u16..100,
) {
let area = Rect::new(0, 0, width, 1);
let mut buf = Buffer::empty(area);
buf.set_string(0, 0, &text, ratatui::style::Style::default());
let md_links = vec![MdLink { text, url }];
let spans = collect_from_buffer_with_md_links(&buf, area, &md_links);
for span in &spans {
prop_assert!(span.start_col < span.end_col);
prop_assert!(span.end_col <= area.x + area.width);
prop_assert!(span.row < area.y + area.height);
}
}
#[test]
fn empty_md_links_matches_collect_from_buffer(
width in 30u16..80,
) {
let area = Rect::new(0, 0, width, 1);
let mut buf = Buffer::empty(area);
buf.set_string(
0, 0,
"visit https://example.com now",
ratatui::style::Style::default(),
);
let baseline = collect_from_buffer(&buf, area);
let with_empty = collect_from_buffer_with_md_links(&buf, area, &[]);
prop_assert_eq!(baseline.len(), with_empty.len());
for (a, b) in baseline.iter().zip(with_empty.iter()) {
prop_assert_eq!(&a.url, &b.url);
prop_assert_eq!(a.start_col, b.start_col);
prop_assert_eq!(a.end_col, b.end_col);
}
}
}
}
}