Skip to content
81 changes: 78 additions & 3 deletions codex-rs/tui/src/markdown_render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::text::Text;
use regex_lite::Regex;
use std::sync::LazyLock;

struct MarkdownStyles {
h1: Style,
Expand Down Expand Up @@ -89,12 +91,29 @@ pub(crate) fn render_markdown_text_with_width(input: &str, width: Option<usize>)
struct LinkState {
destination: String,
show_destination: bool,
hidden_location_suffix: Option<String>,
label_start_span_idx: usize,
label_styled: bool,
}

fn should_render_link_destination(dest_url: &str) -> bool {
!is_local_path_like_link(dest_url)
}

static COLON_LOCATION_SUFFIX_RE: LazyLock<Regex> =
LazyLock::new(
|| match Regex::new(r":\d+(?::\d+)?(?:[-–]\d+(?::\d+)?)?$") {
Ok(regex) => regex,
Err(error) => panic!("invalid location suffix regex: {error}"),
},
);

static HASH_LOCATION_SUFFIX_RE: LazyLock<Regex> =
LazyLock::new(|| match Regex::new(r"^L\d+(?:C\d+)?(?:-L\d+(?:C\d+)?)?$") {
Ok(regex) => regex,
Err(error) => panic!("invalid hash location regex: {error}"),
});

fn is_local_path_like_link(dest_url: &str) -> bool {
dest_url.starts_with("file://")
|| dest_url.starts_with('/')
Expand All @@ -109,6 +128,29 @@ fn is_local_path_like_link(dest_url: &str) -> bool {
)
}

fn hidden_local_link_location_suffix(dest_url: &str) -> Option<String> {
if !is_local_path_like_link(dest_url) {
return None;
}

extract_location_suffix(dest_url).map(std::string::ToString::to_string)
}

fn extract_location_suffix(text: &str) -> Option<&str> {
parse_hash_location_suffix(text).or_else(|| parse_colon_location_suffix(text))
}

fn parse_hash_location_suffix(text: &str) -> Option<&str> {
let (_, fragment) = text.rsplit_once('#')?;
HASH_LOCATION_SUFFIX_RE
.is_match(fragment)
.then_some(text.get(text.len() - fragment.len() - 1..)?)
}

fn parse_colon_location_suffix(text: &str) -> Option<&str> {
COLON_LOCATION_SUFFIX_RE.find(text).map(|m| m.as_str())
}

struct Writer<'a, I>
where
I: Iterator<Item = Event<'a>>,
Expand Down Expand Up @@ -491,20 +533,53 @@ where
}

fn push_link(&mut self, dest_url: String) {
self.push_inline_style(self.styles.link);
let show_destination = should_render_link_destination(&dest_url);
let label_styled = !show_destination;
let label_start_span_idx = self
.current_line_content
.as_ref()
.map(|line| line.spans.len())
.unwrap_or(0);
if label_styled {
self.push_inline_style(self.styles.code);
}
self.link = Some(LinkState {
show_destination: should_render_link_destination(&dest_url),
show_destination,
hidden_location_suffix: hidden_local_link_location_suffix(&dest_url),
label_start_span_idx,
label_styled,
destination: dest_url,
});
}

fn pop_link(&mut self) {
if let Some(link) = self.link.take() {
self.pop_inline_style();
if link.show_destination {
if link.label_styled {
self.pop_inline_style();
}
self.push_span(" (".into());
self.push_span(Span::styled(link.destination, self.styles.link));
self.push_span(")".into());
} else if let Some(location_suffix) = link.hidden_location_suffix.as_deref() {
let label_text = self
.current_line_content
.as_ref()
.map(|line| {
line.spans[link.label_start_span_idx..]
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.unwrap_or_default();
if extract_location_suffix(&label_text).is_none() {
self.push_span(Span::styled(location_suffix.to_string(), self.styles.code));
}
if link.label_styled {
self.pop_inline_style();
}
} else if link.label_styled {
self.pop_inline_style();
}
}
}
Expand Down
95 changes: 90 additions & 5 deletions codex-rs/tui/src/markdown_render_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -643,7 +643,7 @@ fn strong_emphasis() {
fn link() {
let text = render_markdown_text("[Link](https://example.com)");
let expected = Text::from(Line::from_iter([
"Link".cyan().underlined(),
"Link".into(),
" (".into(),
"https://example.com".cyan().underlined(),
")".into(),
Expand All @@ -653,17 +653,102 @@ fn link() {

#[test]
fn file_link_hides_destination() {
let text =
render_markdown_text("[markdown_render.rs:74](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74)");
let expected = Text::from(Line::from("markdown_render.rs:74".cyan().underlined()));
let text = render_markdown_text(
"[codex-rs/tui/src/markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs)",
);
let expected = Text::from(Line::from_iter(["codex-rs/tui/src/markdown_render.rs".cyan()]));
assert_eq!(text, expected);
}

#[test]
fn file_link_appends_line_number_when_label_lacks_it() {
let text = render_markdown_text(
"[markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74)",
);
let expected = Text::from(Line::from_iter([
"markdown_render.rs".cyan(),
":74".cyan(),
]));
assert_eq!(text, expected);
}

#[test]
fn file_link_uses_label_for_line_number() {
let text = render_markdown_text(
"[markdown_render.rs:74](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74)",
);
let expected = Text::from(Line::from_iter(["markdown_render.rs:74".cyan()]));
assert_eq!(text, expected);
}

#[test]
fn file_link_appends_hash_anchor_when_label_lacks_it() {
let text = render_markdown_text(
"[markdown_render.rs](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3)",
);
let expected = Text::from(Line::from_iter([
"markdown_render.rs".cyan(),
"#L74C3".cyan(),
]));
assert_eq!(text, expected);
}

#[test]
fn file_link_uses_label_for_hash_anchor() {
let text = render_markdown_text(
"[markdown_render.rs#L74C3](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3)",
);
let expected = Text::from(Line::from_iter(["markdown_render.rs#L74C3".cyan()]));
assert_eq!(text, expected);
}

#[test]
fn file_link_appends_range_when_label_lacks_it() {
let text = render_markdown_text(
"[markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74:3-76:9)",
);
let expected = Text::from(Line::from_iter([
"markdown_render.rs".cyan(),
":74:3-76:9".cyan(),
]));
assert_eq!(text, expected);
}

#[test]
fn file_link_uses_label_for_range() {
let text = render_markdown_text(
"[markdown_render.rs:74:3-76:9](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74:3-76:9)",
);
let expected = Text::from(Line::from_iter(["markdown_render.rs:74:3-76:9".cyan()]));
assert_eq!(text, expected);
}

#[test]
fn file_link_appends_hash_range_when_label_lacks_it() {
let text = render_markdown_text(
"[markdown_render.rs](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3-L76C9)",
);
let expected = Text::from(Line::from_iter([
"markdown_render.rs".cyan(),
"#L74C3-L76C9".cyan(),
]));
assert_eq!(text, expected);
}

#[test]
fn file_link_uses_label_for_hash_range() {
let text = render_markdown_text(
"[markdown_render.rs#L74C3-L76C9](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3-L76C9)",
);
let expected = Text::from(Line::from_iter(["markdown_render.rs#L74C3-L76C9".cyan()]));
assert_eq!(text, expected);
}

#[test]
fn url_link_shows_destination() {
let text = render_markdown_text("[docs](https://example.com/docs)");
let expected = Text::from(Line::from_iter([
"docs".cyan().underlined(),
"docs".into(),
" (".into(),
"https://example.com/docs".cyan().underlined(),
")".into(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
source: tui/src/markdown_render_tests.rs
assertion_line: 714
expression: rendered
---
See markdown_render.rs:74.
Loading