use super::{DisplayLine, DisplayRole, DisplaySpan, RenderInputKind, render};
use pulldown_cmark::{Alignment, CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
#[cfg(test)]
thread_local! {
static PULLDOWN_RENDER_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
struct MarkdownRenderer {
lines: Vec<DisplayLine>,
current: Vec<DisplaySpan>,
list_stack: Vec<Option<u64>>,
blockquote_depth: usize,
heading_depth: u8,
in_code_block: Option<Option<String>>,
code: String,
table_alignments: Vec<Alignment>,
table_row: Vec<Vec<DisplaySpan>>,
link_url_stack: Vec<String>,
}
impl MarkdownRenderer {
fn new() -> Self {
Self {
lines: Vec::new(),
current: Vec::new(),
list_stack: Vec::new(),
blockquote_depth: 0,
heading_depth: 0,
in_code_block: None,
code: String::new(),
table_alignments: Vec::new(),
table_row: Vec::new(),
link_url_stack: Vec::new(),
}
}
fn handle_event(&mut self, event: Event) {
match event {
Event::Start(Tag::Paragraph) => {
flush_current(&mut self.lines, &mut self.current);
if self.blockquote_depth > 0 {
self.current.push(DisplaySpan::new(
"> ".repeat(self.blockquote_depth),
DisplayRole::BlockQuote,
));
}
}
Event::End(TagEnd::Paragraph) => {
flush_current(&mut self.lines, &mut self.current);
if self.list_stack.is_empty() {
push_blank_if_needed(&mut self.lines);
}
}
Event::Start(Tag::Heading { level, .. }) => {
flush_current(&mut self.lines, &mut self.current);
self.heading_depth = level as u8;
self.current.push(DisplaySpan::new(
"#".repeat(self.heading_depth as usize),
DisplayRole::Heading,
));
self.current
.push(DisplaySpan::new(" ", DisplayRole::Heading));
}
Event::End(TagEnd::Heading(_)) => {
set_role(&mut self.current, DisplayRole::Heading);
flush_current(&mut self.lines, &mut self.current);
self.heading_depth = 0;
push_blank_if_needed(&mut self.lines);
}
Event::Start(Tag::List(start)) => self.list_stack.push(start),
Event::End(TagEnd::List(_)) => {
self.list_stack.pop();
push_blank_if_needed(&mut self.lines);
}
Event::Start(Tag::Item) => {
flush_current(&mut self.lines, &mut self.current);
let marker = match self.list_stack.last_mut() {
Some(Some(next)) => {
let marker = format!("{}. ", *next);
*next = next.saturating_add(1);
marker
}
_ => "• ".to_string(),
};
self.current
.push(DisplaySpan::new(marker, DisplayRole::ListMarker));
}
Event::End(TagEnd::Item) => flush_current(&mut self.lines, &mut self.current),
Event::Start(Tag::BlockQuote(_)) => {
flush_current(&mut self.lines, &mut self.current);
self.blockquote_depth = self.blockquote_depth.saturating_add(1);
self.current.push(DisplaySpan::new(
"> ".repeat(self.blockquote_depth),
DisplayRole::BlockQuote,
));
}
Event::End(TagEnd::BlockQuote(_)) => {
flush_current(&mut self.lines, &mut self.current);
self.blockquote_depth = self.blockquote_depth.saturating_sub(1);
push_blank_if_needed(&mut self.lines);
}
Event::Start(Tag::Table(alignments)) => {
flush_current(&mut self.lines, &mut self.current);
self.table_alignments = alignments;
self.table_row.clear();
}
Event::End(TagEnd::Table) => {
flush_current(&mut self.lines, &mut self.current);
self.table_alignments.clear();
self.table_row.clear();
push_blank_if_needed(&mut self.lines);
}
Event::Start(Tag::TableHead | Tag::TableRow) => {
flush_current(&mut self.lines, &mut self.current);
self.table_row.clear();
}
Event::End(TagEnd::TableHead) => {
flush_current(&mut self.lines, &mut self.current);
let column_count = self.table_row.len().max(self.table_alignments.len());
push_table_row(&mut self.lines, std::mem::take(&mut self.table_row));
push_table_separator(&mut self.lines, &self.table_alignments, column_count);
}
Event::End(TagEnd::TableRow) => {
flush_current(&mut self.lines, &mut self.current);
push_table_row(&mut self.lines, std::mem::take(&mut self.table_row));
}
Event::Start(Tag::TableCell) => {
self.current.clear();
}
Event::End(TagEnd::TableCell) => {
self.table_row.push(std::mem::take(&mut self.current));
}
Event::Start(Tag::CodeBlock(kind)) => {
flush_current(&mut self.lines, &mut self.current);
self.in_code_block = Some(match kind {
CodeBlockKind::Fenced(language) => {
let language = language.trim().to_string();
if language.is_empty() {
None
} else {
Some(language)
}
}
CodeBlockKind::Indented => None,
});
self.code.clear();
}
Event::End(TagEnd::CodeBlock) => {
let language = self.in_code_block.take().flatten();
self.lines.push(DisplayLine::from_span(
format!("```{}", language.as_deref().unwrap_or("")),
DisplayRole::CodeFence,
));
if let Some(language) = &language {
self.lines.push(DisplayLine::from_span(
format!("language: {language}"),
DisplayRole::CodeLanguageLabel,
));
}
let rendered_code = if language.as_deref().is_some_and(is_diff_fence_language) {
render(self.code.trim_end_matches('\n'), RenderInputKind::Diff)
} else {
render(
self.code.trim_end_matches('\n'),
RenderInputKind::Code { language },
)
};
self.lines.extend(add_code_gutter(rendered_code));
self.lines
.push(DisplayLine::from_span("```", DisplayRole::CodeFence));
push_blank_if_needed(&mut self.lines);
}
Event::Text(text) => {
if self.in_code_block.is_some() {
self.code.push_str(&text);
} else {
for (index, part) in text.split('\n').enumerate() {
if index > 0 {
flush_current(&mut self.lines, &mut self.current);
if self.blockquote_depth > 0 {
self.current.push(DisplaySpan::new(
"> ".repeat(self.blockquote_depth),
DisplayRole::BlockQuote,
));
}
}
if !part.is_empty() {
let role = if self.heading_depth > 0 {
DisplayRole::Heading
} else {
DisplayRole::Plain
};
self.current.push(DisplaySpan::new(part, role));
}
}
}
}
Event::Start(Tag::Link { dest_url, .. }) => {
self.link_url_stack.push(dest_url.to_string());
}
Event::End(TagEnd::Link) => {
if let Some(url) = self.link_url_stack.pop()
&& !url.is_empty()
{
self.current
.push(DisplaySpan::new(format!(" ({url})"), DisplayRole::Plain));
}
}
Event::Code(text) => self
.current
.push(DisplaySpan::new(text, DisplayRole::InlineCode)),
Event::SoftBreak => self.current.push(DisplaySpan::new(" ", DisplayRole::Plain)),
Event::HardBreak => flush_current(&mut self.lines, &mut self.current),
Event::Rule => {
flush_current(&mut self.lines, &mut self.current);
self.lines
.push(DisplayLine::from_span("───", DisplayRole::Plain));
}
_ => {}
}
}
fn finish(mut self) -> Vec<DisplayLine> {
flush_current(&mut self.lines, &mut self.current);
trim_trailing_blank(&mut self.lines);
self.lines
}
}
pub(crate) fn render_markdown(input: &str) -> Vec<DisplayLine> {
if input.is_empty() {
return vec![DisplayLine::plain("")];
}
let lines = if input_has_unclosed_fence(input) {
render_unclosed_fence_fallback(input)
} else {
render_with_pulldown(input)
};
if lines.is_empty() {
super::render_plain(input)
} else {
lines
}
}
fn render_with_pulldown(input: &str) -> Vec<DisplayLine> {
#[cfg(test)]
PULLDOWN_RENDER_COUNT.with(|count| count.set(count.get().saturating_add(1)));
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
let parser = Parser::new_ext(input, options);
let mut renderer = MarkdownRenderer::new();
for event in parser {
renderer.handle_event(event);
}
renderer.finish()
}
fn render_unclosed_fence_fallback(input: &str) -> Vec<DisplayLine> {
let lines: Vec<&str> = input.split('\n').collect();
let Some(unclosed_start) = find_unclosed_fence_start(&lines) else {
return render_fallback_fence_lines(&lines);
};
let mut rendered = Vec::new();
if unclosed_start > 0 {
rendered.extend(super::render_plain(&lines[..unclosed_start].join("\n")));
}
rendered.extend(render_fallback_fence_lines(&lines[unclosed_start..]));
rendered
}
fn find_unclosed_fence_start(lines: &[&str]) -> Option<usize> {
let mut opener: Option<(char, usize, usize)> = None;
for (index, line) in lines.iter().enumerate() {
match opener {
Some((opener_char, opener_len, _)) => {
if is_closing_fence(line, opener_char, opener_len) {
opener = None;
}
}
None => {
if let Some((marker_char, marker_len)) = parse_fence_marker(line) {
opener = Some((marker_char, marker_len, index));
}
}
}
}
opener.map(|(_, _, start)| start)
}
fn render_fallback_fence_lines(input_lines: &[&str]) -> Vec<DisplayLine> {
let mut lines = Vec::new();
let mut opener: Option<(char, usize, Option<String>)> = None;
let mut code = Vec::new();
for line in input_lines {
match opener.take() {
Some((opener_char, opener_len, language)) => {
if is_closing_fence(line, opener_char, opener_len) {
render_fallback_code_lines(&mut lines, &mut code, language);
lines.push(DisplayLine::from_span(*line, DisplayRole::CodeFence));
} else {
code.push(*line);
opener = Some((opener_char, opener_len, language));
}
}
None => {
if let Some((marker_char, marker_len)) = parse_fence_marker(line) {
opener = Some((marker_char, marker_len, fence_language(line, marker_len)));
lines.push(DisplayLine::from_span(*line, DisplayRole::CodeFence));
} else {
lines.push(DisplayLine::plain(*line));
}
}
}
}
if let Some((_, _, language)) = opener
&& !code.is_empty()
{
render_fallback_code_lines(&mut lines, &mut code, language);
}
lines
}
fn fence_language(line: &str, marker_len: usize) -> Option<String> {
let indent = line.chars().take_while(|c| *c == ' ').take(4).count();
line[indent + marker_len..]
.split_whitespace()
.next()
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn render_fallback_code_lines(
lines: &mut Vec<DisplayLine>,
code: &mut Vec<&str>,
language: Option<String>,
) {
if code.is_empty() {
return;
}
let code = std::mem::take(code).join("\n");
let rendered_code = if language.as_deref().is_some_and(is_diff_fence_language) {
render(&code, RenderInputKind::Diff)
} else {
render(&code, RenderInputKind::Code { language })
};
lines.extend(add_code_gutter(rendered_code));
}
fn add_code_gutter(lines: Vec<DisplayLine>) -> Vec<DisplayLine> {
lines
.into_iter()
.map(|line| {
let mut spans = Vec::with_capacity(line.spans.len().saturating_add(1));
spans.push(DisplaySpan::new("│ ", DisplayRole::CodeBlockGutter));
spans.extend(line.spans);
DisplayLine { spans }
})
.collect()
}
fn is_diff_fence_language(language: &str) -> bool {
matches!(language.trim(), "diff" | "patch")
}
fn input_has_unclosed_fence(input: &str) -> bool {
let mut opener: Option<(char, usize)> = None;
for line in input.split('\n') {
match opener {
Some((opener_char, opener_len)) => {
if is_closing_fence(line, opener_char, opener_len) {
opener = None;
}
}
None => {
if let Some(parsed) = parse_fence_marker(line) {
opener = Some(parsed);
}
}
}
}
opener.is_some()
}
fn parse_fence_marker(line: &str) -> Option<(char, usize)> {
let indent = line.chars().take_while(|c| *c == ' ').take(4).count();
if indent >= 4 {
return None;
}
let rest = &line[indent..];
let marker_char = rest.chars().next()?;
if marker_char != '`' && marker_char != '~' {
return None;
}
let run_len = rest.chars().take_while(|c| *c == marker_char).count();
if run_len < 3 {
return None;
}
if marker_char == '`' {
let info = &rest[run_len..];
if info.contains('`') {
return None;
}
}
Some((marker_char, run_len))
}
fn is_closing_fence(line: &str, opener_char: char, opener_len: usize) -> bool {
let indent = line.chars().take_while(|c| *c == ' ').take(4).count();
if indent >= 4 {
return false;
}
let rest = &line[indent..];
if !rest.starts_with(opener_char) {
return false;
}
let run_len = rest.chars().take_while(|c| *c == opener_char).count();
if run_len < opener_len {
return false;
}
let after = rest[run_len..].trim();
after.is_empty()
}
fn push_table_row(lines: &mut Vec<DisplayLine>, cells: Vec<Vec<DisplaySpan>>) {
let mut spans = Vec::new();
spans.push(DisplaySpan::new("|", DisplayRole::Plain));
for cell in cells {
spans.push(DisplaySpan::new(" ", DisplayRole::Plain));
spans.extend(cell);
spans.push(DisplaySpan::new(" |", DisplayRole::Plain));
}
lines.push(DisplayLine { spans });
}
fn push_table_separator(
lines: &mut Vec<DisplayLine>,
alignments: &[Alignment],
column_count: usize,
) {
let cells = (0..column_count)
.map(|index| {
let marker = match alignments.get(index).copied().unwrap_or(Alignment::None) {
Alignment::None => "---",
Alignment::Left => ":---",
Alignment::Center => ":---:",
Alignment::Right => "---:",
};
vec![DisplaySpan::new(marker, DisplayRole::Plain)]
})
.collect();
push_table_row(lines, cells);
}
fn flush_current(lines: &mut Vec<DisplayLine>, current: &mut Vec<DisplaySpan>) {
if !current.is_empty() {
lines.push(DisplayLine {
spans: std::mem::take(current),
});
}
}
fn set_role(spans: &mut [DisplaySpan], role: DisplayRole) {
for span in spans {
if span.role == DisplayRole::Plain {
span.role = role;
}
}
}
fn push_blank_if_needed(lines: &mut Vec<DisplayLine>) {
if !lines.last().is_some_and(|line| line.is_blank()) {
lines.push(DisplayLine::plain(""));
}
}
fn trim_trailing_blank(lines: &mut Vec<DisplayLine>) {
while lines.last().is_some_and(|line| line.is_blank()) {
lines.pop();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rendering::plain_projection;
#[test]
fn renders_headings_lists_blockquotes_and_inline_code() {
let lines = render_markdown("# Title\n\n- item with `code`\n1. first\n\n> quoted");
assert!(
lines
.iter()
.flat_map(|line| &line.spans)
.any(|span| span.role == DisplayRole::Heading)
);
assert!(
lines
.iter()
.flat_map(|line| &line.spans)
.any(|span| span.role == DisplayRole::ListMarker)
);
assert!(
lines
.iter()
.flat_map(|line| &line.spans)
.any(|span| span.role == DisplayRole::InlineCode)
);
assert!(
lines
.iter()
.flat_map(|line| &line.spans)
.any(|span| span.role == DisplayRole::BlockQuote)
);
assert!(plain_projection(&lines).contains("# Title"));
}
#[test]
fn renders_markdown_link_destination() {
let lines = render_markdown("[docs](https://example.test/path)");
let projection = plain_projection(&lines);
assert!(
projection.contains("docs"),
"link label must be visible, got: {projection}"
);
assert!(
projection.contains("https://example.test/path"),
"link URL must be visible, got: {projection}"
);
}
#[test]
fn fenced_code_has_boundaries_label_and_highlight_fallback() {
let lines = render_markdown("```rust\nfn main() {}\n```");
assert_eq!(lines.first().unwrap().plain_text(), "```rust");
assert!(
lines
.iter()
.any(|line| line.plain_text() == "language: rust")
);
assert!(
lines
.iter()
.any(|line| line.plain_text() == "│ fn main() {}")
);
assert!(
lines
.iter()
.flat_map(|line| &line.spans)
.any(|span| span.role == DisplayRole::CodeBlockGutter)
);
assert!(
lines
.iter()
.flat_map(|line| &line.spans)
.any(|span| matches!(
span.role,
DisplayRole::Keyword
| DisplayRole::Function
| DisplayRole::Type
| DisplayRole::Punctuation
| DisplayRole::FallbackCode
))
);
}
#[test]
fn unsupported_language_falls_back_readably() {
let lines = render_markdown("```madeup\nabc\n```");
assert!(
lines
.iter()
.flat_map(|line| &line.spans)
.any(|span| span.role == DisplayRole::FallbackCode)
);
assert!(plain_projection(&lines).contains("abc"));
}
#[test]
fn diff_and_patch_fences_use_diff_roles() {
for language in ["diff", "patch"] {
let lines = render_markdown(&format!("```{language}\n-old\n+new\n```"));
assert_eq!(
lines.first().unwrap().plain_text(),
format!("```{language}")
);
assert!(
lines
.iter()
.any(|line| line.plain_text() == format!("language: {language}"))
);
assert!(
lines
.iter()
.flat_map(|line| &line.spans)
.any(|span| span.text == "-old" && span.role == DisplayRole::DiffRemoved)
);
assert!(
lines
.iter()
.flat_map(|line| &line.spans)
.any(|span| span.text == "+new" && span.role == DisplayRole::DiffInserted)
);
}
}
#[test]
fn non_diff_code_fence_keeps_code_highlighting_path() {
let lines = render_markdown("```text\n-old\n+new\n```");
assert!(
!lines
.iter()
.flat_map(|line| &line.spans)
.any(|span| matches!(
span.role,
DisplayRole::DiffInserted
| DisplayRole::DiffRemoved
| DisplayRole::DiffFileHeader
))
);
assert!(
lines
.iter()
.flat_map(|line| &line.spans)
.any(|span| span.role == DisplayRole::FallbackCode)
);
}
#[test]
fn markdown_tables_render_as_separate_rows() {
let lines = render_markdown("| Thing | Status |\n|---|---|\n| Markdown | Works |");
assert_eq!(
plain_projection(&lines),
"| Thing | Status |\n| --- | --- |\n| Markdown | Works |"
);
}
#[test]
fn adjacent_paragraphs_keep_blank_line_boundary() {
let lines = render_markdown("First paragraph\n\nSecond paragraph");
assert_eq!(
plain_projection(&lines),
"First paragraph\n\nSecond paragraph"
);
}
#[test]
fn unclosed_fence_streaming_fallback_is_stable() {
let lines = render_markdown("before\n```rust\nfn main() {");
assert_eq!(lines[0].plain_text(), "before");
assert!(
lines
.iter()
.flat_map(|line| &line.spans)
.any(|span| span.role == DisplayRole::CodeFence)
);
assert!(plain_projection(&lines).contains("fn main()"));
}
#[test]
fn closed_fence_still_uses_pulldown_path() {
PULLDOWN_RENDER_COUNT.with(|count| count.set(0));
let lines = render_markdown("before\n```rust\nfn main() {}\n```\nafter");
assert_eq!(PULLDOWN_RENDER_COUNT.with(|count| count.get()), 1);
assert!(
lines
.iter()
.any(|line| line.plain_text() == "language: rust")
);
assert!(
lines
.iter()
.any(|line| line.plain_text() == "│ fn main() {}")
);
assert!(lines.iter().any(|line| line.plain_text() == "after"));
}
#[test]
fn unclosed_fence_preserves_indented_surrounding_text() {
let lines = render_markdown(" before\n ```text\n indented code");
assert_eq!(lines[0].plain_text(), " before");
assert_eq!(lines[1].plain_text(), " ```text");
assert!(
lines
.iter()
.any(|line| line.plain_text() == "│ indented code")
);
}
#[test]
fn unclosed_diff_and_patch_fences_use_diff_roles() {
for language in ["diff", "patch"] {
let lines = render_markdown(&format!("```{language}\n-old\n+new"));
assert_eq!(
lines.first().unwrap().plain_text(),
format!("```{language}")
);
assert!(
lines
.iter()
.flat_map(|line| &line.spans)
.any(|span| span.text == "-old" && span.role == DisplayRole::DiffRemoved)
);
assert!(
lines
.iter()
.flat_map(|line| &line.spans)
.any(|span| span.text == "+new" && span.role == DisplayRole::DiffInserted)
);
}
}
#[test]
fn unclosed_tilde_fence_uses_streaming_fallback() {
let lines = render_markdown("~~~rust\ncode here");
assert!(!lines.is_empty());
assert!(plain_projection(&lines).contains("code here"));
}
#[test]
fn closed_tilde_fence_uses_pulldown_path() {
let lines = render_markdown("~~~rust\ncode\n~~~");
let projection = plain_projection(&lines);
assert!(projection.contains("code"));
}
#[test]
fn mixed_fence_markers_do_not_close() {
assert!(input_has_unclosed_fence("```rust\ncode\n~~~"));
}
#[test]
fn shorter_closing_fence_does_not_close() {
assert!(input_has_unclosed_fence("``````rust\ncode\n```"));
}
#[test]
fn four_space_indented_fence_is_not_streaming_fallback() {
assert!(!input_has_unclosed_fence(" ```rust\ncode"));
}
#[test]
fn four_space_indented_closing_fence_does_not_close() {
assert!(input_has_unclosed_fence("```rust\ncode\n ```"));
}
#[test]
fn one_to_three_space_indented_fences_still_match() {
assert!(input_has_unclosed_fence(" ```rust\ncode"));
}
#[test]
fn earlier_closed_fence_before_later_unclosed_fence_stays_preserved() {
let input = "```rust\nfoo\n```\n\n~~~rust\nbar";
assert!(input_has_unclosed_fence(input));
let lines = render_markdown(input);
let projection = plain_projection(&lines);
assert!(projection.contains("foo"));
assert!(projection.contains("bar"));
assert!(!lines.iter().any(|line| line.plain_text() == "│ foo"));
assert!(lines.iter().any(|line| line.plain_text() == "│ bar"));
}
#[test]
fn unclosed_fence_does_not_enter_pulldown_path() {
PULLDOWN_RENDER_COUNT.with(|count| count.set(0));
let lines = render_markdown("before\n```rust\nfn main() {");
assert_eq!(PULLDOWN_RENDER_COUNT.with(|count| count.get()), 0);
assert_eq!(
lines,
render_unclosed_fence_fallback("before\n```rust\nfn main() {")
);
}
}