use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthStr;
use crate::theme::Theme;
pub const MAX_MARKDOWN_BYTES: usize = 64 * 1024;
pub fn render_markdown(body: &str, theme: &Theme, width: usize) -> Vec<Line<'static>> {
let cleaned = strip_ansi(body);
if cleaned.len() > MAX_MARKDOWN_BYTES {
return cleaned
.lines()
.map(|line| Line::from(Span::raw(line.to_string())))
.collect();
}
let width = width.max(1);
let parser = Parser::new_ext(&cleaned, Options::empty());
let mut renderer = Renderer::new(theme, width);
for event in parser {
renderer.handle_event(event);
}
renderer.finish()
}
fn strip_ansi(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '\u{1b}' {
out.push(ch);
continue;
}
match chars.peek() {
Some('[') => {
chars.next();
while let Some(&c) = chars.peek() {
chars.next();
if ('\u{40}'..='\u{7e}').contains(&c) {
break;
}
}
}
Some(']') => {
chars.next();
while let Some(&c) = chars.peek() {
chars.next();
if c == '\u{07}' {
break;
}
if c == '\u{1b}' {
if matches!(chars.peek(), Some('\\')) {
chars.next();
}
break;
}
}
}
Some(_) => {
chars.next();
}
None => {}
}
}
out
}
fn strong_style() -> Style {
Style::default().add_modifier(Modifier::BOLD)
}
fn emphasis_style() -> Style {
Style::default().add_modifier(Modifier::ITALIC)
}
fn header_style(theme: &Theme) -> Style {
Style::default()
.fg(theme.markdown_header)
.add_modifier(Modifier::BOLD)
}
fn inline_code_style(theme: &Theme) -> Style {
Style::default()
.fg(theme.markdown_code)
.bg(theme.markdown_code_bg)
}
fn code_block_style(theme: &Theme) -> Style {
Style::default()
.fg(theme.markdown_code)
.bg(theme.markdown_code_bg)
}
fn link_style(theme: &Theme) -> Style {
Style::default()
.fg(theme.markdown_link)
.add_modifier(Modifier::UNDERLINED)
}
fn blockquote_style(theme: &Theme) -> Style {
Style::default()
.fg(theme.markdown_blockquote)
.add_modifier(Modifier::ITALIC)
}
fn rule_style(theme: &Theme) -> Style {
Style::default().fg(theme.markdown_rule)
}
#[derive(Debug, Clone)]
struct BlockContext {
kind: BlockKind,
next_ordinal: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BlockKind {
List,
BlockQuote,
CodeBlock,
Heading,
Paragraph,
Item,
}
struct Renderer<'a> {
theme: &'a Theme,
width: usize,
lines: Vec<Line<'static>>,
current: Vec<Span<'static>>,
style_stack: Vec<Style>,
blocks: Vec<BlockContext>,
code_buffer: String,
code_lang: Option<String>,
link_stack: Vec<String>,
in_heading: Option<HeadingLevel>,
}
impl<'a> Renderer<'a> {
fn new(theme: &'a Theme, width: usize) -> Self {
Self {
theme,
width,
lines: Vec::new(),
current: Vec::new(),
style_stack: Vec::new(),
blocks: Vec::new(),
code_buffer: String::new(),
code_lang: None,
link_stack: Vec::new(),
in_heading: None,
}
}
fn finish(mut self) -> Vec<Line<'static>> {
self.flush_paragraph_line();
self.lines
}
fn in_code_block(&self) -> bool {
self.blocks
.last()
.is_some_and(|b| b.kind == BlockKind::CodeBlock)
}
fn is_blockquote(&self) -> bool {
self.blocks.iter().any(|b| b.kind == BlockKind::BlockQuote)
}
fn current_style(&self) -> Style {
self.style_stack
.iter()
.copied()
.fold(Style::default(), merge_style)
}
fn handle_event(&mut self, event: Event<'_>) {
match event {
Event::Start(tag) => self.handle_start(tag),
Event::End(tag) => self.handle_end(tag),
Event::Text(s) => self.handle_text(&s),
Event::Code(s) => self.handle_inline_code(&s),
Event::Html(s) | Event::InlineHtml(s) => self.handle_text(&s),
Event::SoftBreak => self.handle_soft_break(),
Event::HardBreak => self.hard_break(),
Event::Rule => self.handle_rule(),
Event::FootnoteReference(s) => self.handle_text(&format!("[^{s}]")),
Event::TaskListMarker(checked) => {
let marker = if checked { "[x] " } else { "[ ] " };
self.push_span(Span::raw(marker.to_string()));
}
Event::InlineMath(s) => self.handle_text(&format!("${s}$")),
Event::DisplayMath(s) => self.handle_text(&format!("$${s}$$")),
}
}
fn handle_start(&mut self, tag: Tag<'_>) {
match tag {
Tag::Paragraph => {
self.blocks.push(BlockContext {
kind: BlockKind::Paragraph,
next_ordinal: None,
});
}
Tag::Heading { level, .. } => {
self.flush_paragraph_line();
self.blocks.push(BlockContext {
kind: BlockKind::Heading,
next_ordinal: None,
});
self.in_heading = Some(level);
let marker = "#".repeat(heading_level(level));
let style = header_style(self.theme);
let text = format!("{marker} ");
self.push_span(Span::styled(text, style));
self.style_stack.push(style);
}
Tag::BlockQuote(_) => {
self.flush_paragraph_line();
self.blocks.push(BlockContext {
kind: BlockKind::BlockQuote,
next_ordinal: None,
});
}
Tag::CodeBlock(kind) => {
self.flush_paragraph_line();
self.blocks.push(BlockContext {
kind: BlockKind::CodeBlock,
next_ordinal: None,
});
self.code_buffer.clear();
self.code_lang = match kind {
CodeBlockKind::Fenced(lang) if !lang.is_empty() => Some(lang.to_string()),
_ => None,
};
let style = code_block_style(self.theme);
let opener = match &self.code_lang {
Some(lang) => format!("``` {lang}"),
None => "```".to_string(),
};
self.emit_line_with_prefix(vec![Span::styled(opener, style)]);
}
Tag::List(start) => {
self.flush_paragraph_line();
self.blocks.push(BlockContext {
kind: BlockKind::List,
next_ordinal: start,
});
}
Tag::Item => {
self.flush_paragraph_line();
let (bullet, style) = if let Some(list) = self
.blocks
.iter_mut()
.rev()
.find(|b| b.kind == BlockKind::List)
{
match list.next_ordinal.as_mut() {
Some(n) => {
let s = format!("{n}. ");
*n += 1;
(s, Style::default())
}
None => ("- ".to_string(), Style::default()),
}
} else {
("- ".to_string(), Style::default())
};
self.blocks.push(BlockContext {
kind: BlockKind::Item,
next_ordinal: None,
});
self.push_span(Span::styled(bullet, style));
}
Tag::Emphasis => {
let style = emphasis_style();
self.style_stack.push(style);
}
Tag::Strong => {
let style = strong_style();
self.style_stack.push(style);
}
Tag::Strikethrough => {
let style = Style::default().add_modifier(Modifier::CROSSED_OUT);
self.style_stack.push(style);
}
Tag::Link { dest_url, .. } => {
self.link_stack.push(dest_url.to_string());
let style = link_style(self.theme);
self.style_stack.push(style);
}
Tag::Image { dest_url, .. } => {
self.link_stack.push(dest_url.to_string());
let style = link_style(self.theme);
self.push_span(Span::styled("!".to_string(), style));
self.style_stack.push(style);
}
Tag::HtmlBlock => {
self.blocks.push(BlockContext {
kind: BlockKind::Paragraph,
next_ordinal: None,
});
}
_ => {
self.blocks.push(BlockContext {
kind: BlockKind::Paragraph,
next_ordinal: None,
});
}
}
}
fn handle_end(&mut self, tag: TagEnd) {
match tag {
TagEnd::Paragraph => {
self.flush_paragraph_line();
self.blocks.pop();
}
TagEnd::Heading(_) => {
self.style_stack.pop();
self.flush_paragraph_line();
self.blocks.pop();
self.in_heading = None;
}
TagEnd::BlockQuote(_) => {
self.flush_paragraph_line();
self.blocks.pop();
}
TagEnd::CodeBlock => {
let style = code_block_style(self.theme);
let buf = std::mem::take(&mut self.code_buffer);
for line in buf.split('\n') {
if line.is_empty() && buf.ends_with('\n') {
continue;
}
self.emit_line_with_prefix(vec![Span::styled(line.to_string(), style)]);
}
self.emit_line_with_prefix(vec![Span::styled("```".to_string(), style)]);
self.code_lang = None;
self.blocks.pop();
}
TagEnd::List(_) => {
self.flush_paragraph_line();
self.blocks.pop();
}
TagEnd::Item => {
self.flush_paragraph_line();
self.blocks.pop();
}
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => {
self.style_stack.pop();
}
TagEnd::Link => {
self.style_stack.pop();
if let Some(url) = self.link_stack.pop() {
let style = link_style(self.theme);
self.push_span(Span::styled(format!(" ({url})"), style));
}
}
TagEnd::Image => {
self.style_stack.pop();
if let Some(url) = self.link_stack.pop() {
let style = link_style(self.theme);
self.push_span(Span::styled(format!(" ({url})"), style));
}
}
TagEnd::HtmlBlock => {
self.flush_paragraph_line();
self.blocks.pop();
}
_ => {
self.blocks.pop();
}
}
}
fn handle_text(&mut self, text: &str) {
if self.in_code_block() {
self.code_buffer.push_str(text);
return;
}
let style = self.current_style();
for (i, chunk) in split_to_width(text, self.remaining_width())
.into_iter()
.enumerate()
{
if i > 0 {
self.flush_paragraph_line();
}
if !chunk.is_empty() {
self.push_span(Span::styled(chunk, style));
}
}
}
fn handle_inline_code(&mut self, text: &str) {
let style = inline_code_style(self.theme);
let combined = merge_style(self.current_style(), style);
for (i, chunk) in split_to_width(text, self.remaining_width())
.into_iter()
.enumerate()
{
if i > 0 {
self.flush_paragraph_line();
}
if !chunk.is_empty() {
self.push_span(Span::styled(chunk, combined));
}
}
}
fn handle_soft_break(&mut self) {
if self.in_heading.is_some() {
self.push_span(Span::raw(" ".to_string()));
return;
}
self.push_span(Span::raw(" ".to_string()));
}
fn hard_break(&mut self) {
self.flush_paragraph_line();
}
fn handle_rule(&mut self) {
self.flush_paragraph_line();
let line_width = self.width.max(3);
let rule = "-".repeat(line_width);
self.emit_line_with_prefix(vec![Span::styled(rule, rule_style(self.theme))]);
}
fn remaining_width(&self) -> usize {
let prefix = self.compute_prefix();
let used: usize = self.current.iter().map(|s| s.content.width()).sum();
self.width
.saturating_sub(prefix.width())
.saturating_sub(used)
.max(1)
}
fn compute_prefix(&self) -> String {
let mut prefix = String::new();
let quote_depth = self
.blocks
.iter()
.filter(|b| b.kind == BlockKind::BlockQuote)
.count();
for _ in 0..quote_depth {
prefix.push_str("> ");
}
let list_depth = self
.blocks
.iter()
.filter(|b| b.kind == BlockKind::List)
.count();
let item_depth = self
.blocks
.iter()
.filter(|b| b.kind == BlockKind::Item)
.count();
let wrap_indent = list_depth.saturating_add(item_depth).saturating_sub(1);
for _ in 0..wrap_indent {
prefix.push_str(" ");
}
prefix
}
fn push_span(&mut self, span: Span<'static>) {
self.current.push(span);
}
fn emit_line_with_prefix(&mut self, content: Vec<Span<'static>>) {
let prefix = self.compute_prefix();
let mut spans = Vec::with_capacity(content.len() + 1);
if !prefix.is_empty() {
let style = if self.is_blockquote() {
blockquote_style(self.theme)
} else {
Style::default()
};
spans.push(Span::styled(prefix, style));
}
spans.extend(content);
self.lines.push(Line::from(spans));
}
fn flush_paragraph_line(&mut self) {
if self.current.is_empty() {
return;
}
let spans = std::mem::take(&mut self.current);
let quote_style = if self.is_blockquote() {
Some(blockquote_style(self.theme))
} else {
None
};
let spans = match quote_style {
Some(q_style) => spans
.into_iter()
.map(|s| {
Span::styled(s.content, merge_style(q_style, s.style))
})
.collect(),
None => spans,
};
self.emit_line_with_prefix(spans);
}
}
pub fn highlight_file_refs(
lines: Vec<Line<'static>>,
style: Style,
code_color: ratatui::style::Color,
) -> Vec<Line<'static>> {
lines
.into_iter()
.map(|line| Line::from(highlight_file_refs_in_spans(line.spans, style, code_color)))
.collect()
}
fn highlight_file_refs_in_spans(
spans: Vec<Span<'static>>,
style: Style,
code_color: ratatui::style::Color,
) -> Vec<Span<'static>> {
let mut out: Vec<Span<'static>> = Vec::with_capacity(spans.len());
let mut prev_char: Option<char> = None;
for span in spans {
if span.style.fg == Some(code_color) {
prev_char = span.content.chars().last();
out.push(span);
continue;
}
let original_style = span.style;
let text = span.content.into_owned();
let mut idx = 0usize;
let bytes = text.as_bytes();
while idx < bytes.len() {
let at_idx = text[idx..].find('@').map(|off| idx + off);
let Some(at_idx) = at_idx else {
out.push(Span::styled(text[idx..].to_string(), original_style));
prev_char = text[idx..].chars().last();
break;
};
if at_idx > idx {
out.push(Span::styled(text[idx..at_idx].to_string(), original_style));
prev_char = text[idx..at_idx].chars().last();
}
let boundary_ok = match prev_char {
None => true,
Some(c) => c.is_whitespace(),
};
if !boundary_ok {
out.push(Span::styled("@".to_string(), original_style));
prev_char = Some('@');
idx = at_idx + 1;
continue;
}
let after = at_idx + 1;
let mut end = after;
while end < text.len() {
let c = text[end..].chars().next().unwrap();
if is_path_char(c) {
end += c.len_utf8();
} else {
break;
}
}
if end < text.len()
&& text.as_bytes()[end] == b':'
&& end + 1 < text.len()
&& text.as_bytes()[end + 1].is_ascii_digit()
{
end += 1;
while end < text.len() && text.as_bytes()[end].is_ascii_digit() {
end += 1;
}
}
if end == after {
out.push(Span::styled("@".to_string(), original_style));
prev_char = Some('@');
idx = after;
continue;
}
out.push(Span::styled(text[at_idx..end].to_string(), style));
prev_char = text[at_idx..end].chars().last();
idx = end;
}
}
out
}
fn is_path_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '-')
}
fn heading_level(level: HeadingLevel) -> usize {
match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
}
}
fn merge_style(base: Style, layer: Style) -> Style {
let mut out = base;
if let Some(fg) = layer.fg {
out = out.fg(fg);
}
if let Some(bg) = layer.bg {
out = out.bg(bg);
}
out = out.add_modifier(layer.add_modifier);
if !layer.sub_modifier.is_empty() {
out = out.remove_modifier(layer.sub_modifier);
}
if let Some(color) = layer.underline_color {
out = out.underline_color(color);
}
out
}
fn split_to_width(text: &str, width: usize) -> Vec<String> {
let width = width.max(1);
if text.is_empty() {
return vec![String::new()];
}
if text.width() <= width {
return vec![text.to_string()];
}
let mut out = Vec::new();
let mut current = String::new();
let mut current_width = 0usize;
let words = split_preserving_whitespace(text);
for word in words {
let w_width = word.width();
if current_width == 0 && w_width > width {
let mut remaining = word.as_str();
while !remaining.is_empty() {
let (chunk, rest) = take_up_to_width(remaining, width);
out.push(chunk);
remaining = rest;
}
continue;
}
if current_width + w_width > width {
out.push(std::mem::take(&mut current));
current_width = 0;
if word.chars().all(char::is_whitespace) {
continue;
}
}
current.push_str(&word);
current_width += w_width;
}
if !current.is_empty() {
out.push(current);
}
if out.is_empty() {
out.push(String::new());
}
out
}
fn split_preserving_whitespace(text: &str) -> std::vec::IntoIter<String> {
let mut out = Vec::new();
let mut buf = String::new();
let mut in_ws = false;
for ch in text.chars() {
let ws = ch.is_whitespace();
if buf.is_empty() {
in_ws = ws;
buf.push(ch);
continue;
}
if ws == in_ws {
buf.push(ch);
} else {
out.push(std::mem::take(&mut buf));
in_ws = ws;
buf.push(ch);
}
}
if !buf.is_empty() {
out.push(buf);
}
out.into_iter()
}
fn take_up_to_width(text: &str, width: usize) -> (String, &str) {
let mut end = 0usize;
let mut used = 0usize;
for (i, ch) in text.char_indices() {
let cw = UnicodeWidthStr::width(ch.to_string().as_str());
if used + cw > width && used > 0 {
break;
}
used += cw;
end = i + ch.len_utf8();
}
(text[..end].to_string(), &text[end..])
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::theme::Theme;
fn theme() -> Theme {
Theme::dark()
}
fn line_text(line: &Line<'_>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn headers_are_bold_and_prefixed_with_hashes() {
let theme = theme();
let lines = render_markdown("# Hello\n## World\n### Sub", &theme, 80);
assert_eq!(lines.len(), 3, "should produce one line per header");
assert_eq!(line_text(&lines[0]), "# Hello");
assert_eq!(line_text(&lines[1]), "## World");
assert_eq!(line_text(&lines[2]), "### Sub");
let style = lines[0].spans[0].style;
assert_eq!(style.fg, Some(theme.markdown_header));
assert!(style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn emphasis_spans_carry_italic_and_bold_modifiers() {
let theme = theme();
let lines = render_markdown("Regular **bold** and *italic* text.", &theme, 80);
assert_eq!(lines.len(), 1);
let line = &lines[0];
let bold = line
.spans
.iter()
.find(|s| s.content.as_ref() == "bold")
.expect("bold span present");
assert!(bold.style.add_modifier.contains(Modifier::BOLD));
let italic = line
.spans
.iter()
.find(|s| s.content.as_ref() == "italic")
.expect("italic span present");
assert!(italic.style.add_modifier.contains(Modifier::ITALIC));
}
#[test]
fn inline_code_gets_code_colours() {
let theme = theme();
let lines = render_markdown("call `foo()` here", &theme, 80);
let line = &lines[0];
let code = line
.spans
.iter()
.find(|s| s.content.as_ref() == "foo()")
.expect("inline code span present");
assert_eq!(code.style.fg, Some(theme.markdown_code));
assert_eq!(code.style.bg, Some(theme.markdown_code_bg));
}
#[test]
fn fenced_code_block_preserves_lines_and_wraps_in_fences() {
let theme = theme();
let body = "```rust\nfn main() {\n println!(\"hi\");\n}\n```";
let lines = render_markdown(body, &theme, 80);
assert!(
lines.len() >= 5,
"expected at least 5 lines, got {}",
lines.len()
);
let opener = line_text(&lines[0]);
assert!(opener.contains("```"), "opener: {opener:?}");
assert!(opener.contains("rust"));
let mid = &lines[1];
let content_span = mid.spans.last().expect("content span on code line");
assert_eq!(content_span.style.fg, Some(theme.markdown_code));
assert_eq!(content_span.style.bg, Some(theme.markdown_code_bg));
let closer = line_text(&lines[lines.len() - 1]);
assert_eq!(closer, "```");
}
#[test]
fn nested_lists_indent_wrapped_bullet_text() {
let theme = theme();
let body = "- Outer\n - Inner one\n - Inner two\n- Second";
let lines = render_markdown(body, &theme, 80);
let texts: Vec<_> = lines.iter().map(line_text).collect();
assert!(
texts.iter().any(|t| t.contains("- Outer")),
"expected outer bullet line, got {texts:?}"
);
let inner_one_leading = texts
.iter()
.find(|t| t.contains("Inner one"))
.map(|t| t.chars().take_while(|c| *c == ' ').count())
.expect("inner one line");
let outer_leading = texts
.iter()
.find(|t| t.contains("Outer"))
.map(|t| t.chars().take_while(|c| *c == ' ').count())
.expect("outer line");
assert!(
inner_one_leading > outer_leading,
"expected inner bullet to be more indented: inner={inner_one_leading} outer={outer_leading} lines={texts:?}"
);
let second_leading = texts
.iter()
.find(|t| t.contains("Second"))
.map(|t| t.chars().take_while(|c| *c == ' ').count())
.expect("second line");
assert_eq!(
second_leading, outer_leading,
"top-level bullets should share indent; lines={texts:?}"
);
}
#[test]
fn ordered_lists_number_each_item() {
let theme = theme();
let body = "1. first\n2. second\n3. third";
let lines = render_markdown(body, &theme, 80);
let texts: Vec<_> = lines.iter().map(line_text).collect();
assert!(
texts.iter().any(|t| t.contains("1. first")),
"expected ordered item 1, got {texts:?}"
);
assert!(
texts.iter().any(|t| t.contains("2. second")),
"expected ordered item 2, got {texts:?}"
);
assert!(
texts.iter().any(|t| t.contains("3. third")),
"expected ordered item 3, got {texts:?}"
);
}
#[test]
fn links_render_text_followed_by_url_in_link_style() {
let theme = theme();
let lines = render_markdown("see [docs](https://example.com/docs) for more", &theme, 80);
let spans = &lines[0].spans;
let label = spans
.iter()
.find(|s| s.content.as_ref() == "docs")
.expect("link label span");
assert_eq!(label.style.fg, Some(theme.markdown_link));
assert!(
label.style.add_modifier.contains(Modifier::UNDERLINED),
"link label should be underlined"
);
let url = spans
.iter()
.find(|s| s.content.contains("https://example.com/docs"))
.expect("url span present");
assert_eq!(url.style.fg, Some(theme.markdown_link));
}
#[test]
fn blockquote_lines_are_prefixed_with_gt_and_italicised() {
let theme = theme();
let body = "> quoted\n> second line";
let lines = render_markdown(body, &theme, 80);
assert!(!lines.is_empty());
let quoted_line = &lines[0];
assert!(quoted_line.spans[0].content.starts_with("> "));
assert_eq!(
quoted_line.spans[0].style.fg,
Some(theme.markdown_blockquote)
);
let italic = quoted_line
.spans
.iter()
.find(|s| s.content.as_ref() == "quoted second line")
.or_else(|| {
quoted_line
.spans
.iter()
.find(|s| s.content.contains("quoted"))
})
.expect("quote body span");
assert!(italic.style.add_modifier.contains(Modifier::ITALIC));
}
#[test]
fn thematic_break_renders_as_dashes_line() {
let theme = theme();
let lines = render_markdown("before\n\n---\n\nafter", &theme, 12);
let texts: Vec<_> = lines.iter().map(line_text).collect();
assert!(
texts
.iter()
.any(|t| t.chars().all(|c| c == '-') && !t.is_empty()),
"expected a dashes-only line in {texts:?}"
);
}
#[test]
fn ansi_escape_sequences_are_stripped() {
let theme = theme();
let body = "plain \u{1b}[31mdanger\u{1b}[0m after";
let lines = render_markdown(body, &theme, 80);
let text = line_text(&lines[0]);
assert_eq!(text, "plain danger after");
let body = "x\u{1b}]0;title\u{07}y";
let lines = render_markdown(body, &theme, 80);
assert_eq!(line_text(&lines[0]), "xy");
}
#[test]
fn oversized_body_falls_back_to_plain_text() {
let theme = theme();
let chunk = "line of plain text\n";
let body: String = chunk.repeat((MAX_MARKDOWN_BYTES / chunk.len()) + 10);
assert!(body.len() > MAX_MARKDOWN_BYTES);
let lines = render_markdown(&body, &theme, 80);
assert!(lines.len() > 100);
for l in &lines {
assert_eq!(l.spans.len(), 1, "plain fallback should be single span");
assert_eq!(l.spans[0].style, Style::default());
}
}
#[test]
fn paragraphs_wrap_to_requested_width() {
let theme = theme();
let body = "alpha beta gamma delta epsilon";
let lines = render_markdown(body, &theme, 10);
assert!(
lines.len() >= 2,
"expected wrap across >=2 lines, got {lines:?}"
);
for line in &lines {
let w: usize = line.spans.iter().map(|s| s.content.as_ref().width()).sum();
assert!(w <= 12, "line too wide: {w} ({:?})", line_text(line));
}
}
#[test]
fn html_passes_through_literally() {
let theme = theme();
let body = "<b>hi</b>";
let lines = render_markdown(body, &theme, 80);
let joined: String = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(joined.contains("<b>") && joined.contains("</b>"));
}
#[test]
fn urls_are_preserved_verbatim() {
let theme = theme();
let body = "[x](https://a.example/path?q=1&r=2#frag)";
let lines = render_markdown(body, &theme, 200);
let joined: String = lines.iter().map(line_text).collect::<String>();
assert!(
joined.contains("https://a.example/path?q=1&r=2#frag"),
"url not preserved: {joined:?}"
);
}
#[test]
fn strip_ansi_leaves_plain_text_alone() {
assert_eq!(strip_ansi("no escapes here"), "no escapes here");
}
#[test]
fn strip_ansi_handles_common_csi_sequences() {
assert_eq!(strip_ansi("\u{1b}[1;31mred\u{1b}[0m ok"), "red ok");
}
fn highlight_style() -> Style {
Style::default().fg(ratatui::style::Color::Cyan)
}
fn code_color() -> ratatui::style::Color {
ratatui::style::Color::Rgb(220, 220, 140)
}
fn highlight_spans(text: &str) -> Vec<Span<'static>> {
let input = vec![Line::from(Span::raw(text.to_string()))];
let out = highlight_file_refs(input, highlight_style(), code_color());
out.into_iter().next().unwrap().spans
}
#[test]
fn highlights_at_file_token() {
let spans = highlight_spans("see @src/foo.rs for context");
let hit = spans
.iter()
.find(|s| s.content.as_ref() == "@src/foo.rs")
.expect("file-ref span present");
assert_eq!(hit.style.fg, Some(ratatui::style::Color::Cyan));
}
#[test]
fn highlights_at_file_with_line() {
let spans = highlight_spans("check @src/main.rs:42");
let hit = spans
.iter()
.find(|s| s.content.as_ref() == "@src/main.rs:42")
.expect("file-ref with line span present");
assert_eq!(hit.style.fg, Some(ratatui::style::Color::Cyan));
}
#[test]
fn does_not_highlight_email_like_at_symbol() {
let spans = highlight_spans("ping foo@bar.com today");
for span in &spans {
if span.content.starts_with('@') {
assert_ne!(
span.style.fg,
Some(ratatui::style::Color::Cyan),
"email fragment {:?} was promoted to a file reference",
span.content
);
}
}
}
#[test]
fn handles_multiple_refs_in_one_line() {
let spans = highlight_spans("edit @a/b.rs and @c.rs:7 plus text");
let refs: Vec<&str> = spans
.iter()
.filter(|s| s.style.fg == Some(ratatui::style::Color::Cyan))
.map(|s| s.content.as_ref())
.collect();
assert!(refs.contains(&"@a/b.rs"), "first ref missing: {refs:?}");
assert!(refs.contains(&"@c.rs:7"), "second ref missing: {refs:?}");
}
#[test]
fn preserves_existing_span_styles_for_non_matching_text() {
let body = "call `foo()` here";
let lines = render_markdown(body, &theme(), 80);
let out = highlight_file_refs(lines, highlight_style(), theme().markdown_code);
let code = out[0]
.spans
.iter()
.find(|s| s.content.as_ref() == "foo()")
.expect("inline code span preserved");
assert_eq!(code.style.fg, Some(theme().markdown_code));
}
}