use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use std::sync::OnceLock;
use unicode_width::UnicodeWidthStr;
static SYNTAX_SET: OnceLock<syntect::parsing::SyntaxSet> = OnceLock::new();
static THEME_SET: OnceLock<syntect::highlighting::ThemeSet> = OnceLock::new();
fn get_syntax_set() -> &'static syntect::parsing::SyntaxSet {
SYNTAX_SET.get_or_init(syntect::parsing::SyntaxSet::load_defaults_newlines)
}
fn get_theme_set() -> &'static syntect::highlighting::ThemeSet {
THEME_SET.get_or_init(syntect::highlighting::ThemeSet::load_defaults)
}
fn inline_code_style() -> Style {
Style::default().fg(Color::Yellow).bg(Color::DarkGray)
}
fn math_style() -> Style {
Style::default().fg(Color::LightMagenta).bg(Color::DarkGray)
}
fn heading_style(level: HeadingLevel) -> Style {
let color = match level {
HeadingLevel::H1 => Color::Cyan,
HeadingLevel::H2 => Color::LightCyan,
HeadingLevel::H3 => Color::White,
_ => Color::Gray,
};
Style::default().fg(color).add_modifier(Modifier::BOLD)
}
fn bold_style() -> Style {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
}
fn italic_style() -> Style {
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::ITALIC)
}
fn strikethrough_style() -> Style {
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::CROSSED_OUT)
}
fn link_style() -> Style {
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED)
}
fn text_style() -> Style {
Style::default().fg(Color::Gray)
}
fn bullet_style() -> Style {
Style::default().fg(Color::DarkGray)
}
pub fn render_markdown(text: &str, max_width: usize) -> Vec<Line<'static>> {
if text.is_empty() {
return Vec::new();
}
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TASKLISTS);
options.insert(Options::ENABLE_MATH);
let parser = Parser::new_ext(text, options);
let mut renderer = MarkdownRenderer::new(max_width);
renderer.render(parser);
renderer.lines
}
struct MarkdownRenderer {
lines: Vec<Line<'static>>,
current_spans: Vec<Span<'static>>,
style_stack: Vec<Style>,
in_code_block: bool,
code_block_lang: Option<String>,
code_block_content: String,
list_depth: usize,
max_width: usize,
current_line_width: usize,
in_table_cell: bool,
current_table_row: Vec<String>,
current_cell_content: String,
table_header: Vec<String>,
table_rows: Vec<Vec<String>>,
in_table_header: bool,
}
impl MarkdownRenderer {
fn new(max_width: usize) -> Self {
Self {
lines: Vec::new(),
current_spans: Vec::new(),
style_stack: vec![text_style()],
in_code_block: false,
code_block_lang: None,
code_block_content: String::new(),
list_depth: 0,
max_width,
current_line_width: 0,
in_table_cell: false,
current_table_row: Vec::new(),
current_cell_content: String::new(),
table_header: Vec::new(),
table_rows: Vec::new(),
in_table_header: false,
}
}
fn current_style(&self) -> Style {
*self.style_stack.last().unwrap_or(&text_style())
}
fn push_style(&mut self, style: Style) {
self.style_stack.push(style);
}
fn pop_style(&mut self) {
if self.style_stack.len() > 1 {
self.style_stack.pop();
}
}
fn flush_line(&mut self) {
if !self.current_spans.is_empty() {
self.lines.push(Line::from(self.current_spans.clone()));
self.current_spans.clear();
self.current_line_width = 0;
}
}
fn add_text(&mut self, text: &str) {
let style = self.current_style();
for word in text.split_whitespace() {
let word_width = word.width();
if word_width > self.max_width {
let mut remaining = word;
while !remaining.is_empty() {
let available = self.max_width.saturating_sub(self.current_line_width);
if available == 0 {
self.flush_line();
continue;
}
let mut chars_len = 0;
let mut fit_width = 0;
for ch in remaining.chars() {
let ch_w = if ch > '\u{7F}' { 2 } else { 1 };
if fit_width + ch_w > available {
break;
}
chars_len += ch.len_utf8();
fit_width += ch_w;
}
if chars_len > 0 {
self.current_spans
.push(Span::styled(remaining[..chars_len].to_string(), style));
self.current_line_width += fit_width;
remaining = &remaining[chars_len..];
}
if !remaining.is_empty() {
self.flush_line();
}
}
} else {
let needs_space = self.current_line_width > 0;
let total_width = word_width + if needs_space { 1 } else { 0 };
if self.current_line_width + total_width > self.max_width {
self.flush_line();
self.current_spans
.push(Span::styled(word.to_string(), style));
self.current_line_width = word_width;
} else {
if needs_space {
self.current_spans
.push(Span::styled(" ".to_string(), style));
self.current_line_width += 1;
}
self.current_spans
.push(Span::styled(word.to_string(), style));
self.current_line_width += word_width;
}
}
}
}
fn render(&mut self, parser: Parser) {
for event in parser {
match event {
Event::Start(tag) => self.handle_start(tag),
Event::End(tag_end) => self.handle_end(tag_end),
Event::Text(text) => {
if self.in_code_block {
self.code_block_content.push_str(&text);
} else if self.in_table_cell {
self.current_cell_content.push_str(&text);
} else {
self.add_text(&text);
}
}
Event::Code(code) => {
if self.in_table_cell {
self.current_cell_content.push_str(&code);
} else {
self.current_spans
.push(Span::styled(code.to_string(), inline_code_style()));
}
}
Event::InlineMath(math) => {
if self.in_table_cell {
self.current_cell_content.push_str(&format!("${}$", math));
} else {
self.current_spans
.push(Span::styled(format!("${}$", math), math_style()));
}
}
Event::DisplayMath(math) => {
self.flush_line();
self.lines
.push(Line::styled(format!(" $${}$$", math), math_style()));
}
Event::Html(html) => {
if self.in_table_cell {
self.current_cell_content.push_str(&html);
} else {
self.add_text(&html);
}
}
Event::InlineHtml(html) => {
if self.in_table_cell {
self.current_cell_content.push_str(&html);
} else {
self.add_text(&html);
}
}
Event::SoftBreak => self.add_text(" "),
Event::HardBreak => self.flush_line(),
Event::Rule => {
self.flush_line();
self.lines.push(Line::styled(
"─".repeat(40),
Style::default().fg(Color::DarkGray),
));
}
Event::FootnoteReference(_) | Event::TaskListMarker(_) => {}
}
}
self.flush_line();
}
fn handle_start(&mut self, tag: Tag) {
match tag {
Tag::Heading { level, .. } => {
self.flush_line();
self.push_style(heading_style(level));
}
Tag::Paragraph => {
self.flush_line();
}
Tag::CodeBlock(kind) => {
self.flush_line();
self.in_code_block = true;
self.code_block_lang = match kind {
CodeBlockKind::Fenced(lang) => Some(lang.to_string()),
CodeBlockKind::Indented => None,
};
}
Tag::List(_) => {
self.flush_line();
self.list_depth += 1;
}
Tag::Item => {
self.flush_line();
let indent = " ".repeat(self.list_depth.saturating_sub(1));
self.current_spans
.push(Span::styled(format!("{}• ", indent), bullet_style()));
}
Tag::BlockQuote(_) => {
self.flush_line();
self.push_style(Style::default().fg(Color::DarkGray));
self.current_spans
.push(Span::styled("│ ", Style::default().fg(Color::DarkGray)));
}
Tag::Strong => {
if self.in_table_cell {
} else {
self.push_style(bold_style());
}
}
Tag::Emphasis => {
if self.in_table_cell {
} else {
self.push_style(italic_style());
}
}
Tag::Strikethrough => {
if self.in_table_cell {
} else {
self.push_style(strikethrough_style());
}
}
Tag::Link { dest_url: _, .. } => {
if self.in_table_cell {
} else {
self.push_style(link_style());
self.current_spans
.push(Span::styled("[", Style::default().fg(Color::DarkGray)));
}
}
Tag::Image { title, .. } => {
self.add_text("📷 ");
if !title.is_empty() {
self.add_text(&title);
}
}
Tag::Table(_) => {
self.flush_line();
self.table_header.clear();
self.table_rows.clear();
}
Tag::TableHead => {
self.in_table_header = true;
}
Tag::TableRow => {
self.current_table_row.clear();
}
Tag::TableCell => {
self.current_cell_content.clear();
self.in_table_cell = true;
}
Tag::FootnoteDefinition(_) => {}
Tag::HtmlBlock => {}
Tag::DefinitionList => {}
Tag::DefinitionListTitle => {}
Tag::DefinitionListDefinition => {}
Tag::Superscript => self.push_style(Style::default().fg(Color::Gray)),
Tag::Subscript => self.push_style(Style::default().fg(Color::Gray)),
Tag::MetadataBlock(_) => {}
}
}
fn handle_end(&mut self, tag_end: TagEnd) {
match tag_end {
TagEnd::Heading(_) => {
self.flush_line();
self.pop_style();
}
TagEnd::Paragraph => {
self.flush_line();
self.lines.push(Line::raw(""));
}
TagEnd::CodeBlock => {
self.flush_code_block();
self.in_code_block = false;
self.code_block_lang = None;
self.code_block_content.clear();
}
TagEnd::List(_) => {
self.flush_line();
self.list_depth = self.list_depth.saturating_sub(1);
}
TagEnd::Item => {
self.flush_line();
}
TagEnd::BlockQuote(_) => {
self.flush_line();
self.pop_style();
}
TagEnd::Strong => {
if !self.in_table_cell {
self.pop_style();
}
}
TagEnd::Emphasis => {
if !self.in_table_cell {
self.pop_style();
}
}
TagEnd::Strikethrough => {
if !self.in_table_cell {
self.pop_style();
}
}
TagEnd::Link => {
if !self.in_table_cell {
self.pop_style();
self.current_spans
.push(Span::styled("]", Style::default().fg(Color::DarkGray)));
}
}
TagEnd::Image => {}
TagEnd::Table => {
self.flush_line();
self.render_table();
}
TagEnd::TableHead => {
self.table_header = self.current_table_row.clone();
self.current_table_row.clear();
self.in_table_header = false;
}
TagEnd::TableRow => {
if self.in_table_header {
self.table_header = self.current_table_row.clone();
} else {
self.table_rows.push(self.current_table_row.clone());
}
self.current_table_row.clear();
}
TagEnd::TableCell => {
self.current_table_row
.push(self.current_cell_content.clone());
self.current_cell_content.clear();
self.in_table_cell = false;
}
TagEnd::FootnoteDefinition => {}
TagEnd::HtmlBlock => {}
TagEnd::DefinitionList => {}
TagEnd::DefinitionListTitle => {}
TagEnd::DefinitionListDefinition => {}
TagEnd::Superscript => self.pop_style(),
TagEnd::Subscript => self.pop_style(),
TagEnd::MetadataBlock(_) => {}
}
}
fn flush_code_block(&mut self) {
let lang = self.code_block_lang.as_deref().unwrap_or("");
let code = &self.code_block_content;
if !lang.is_empty() {
self.lines.push(Line::styled(
format!("// {}", lang),
Style::default().fg(Color::DarkGray),
));
}
for line_spans in self.highlight_code_with_colors(lang, code) {
let total_width: usize = line_spans.iter().map(|s| s.content.width()).sum();
if total_width <= self.max_width || self.max_width < 10 {
self.lines.push(Line::from(line_spans));
} else {
let mut current_spans: Vec<Span> = Vec::new();
let mut current_width = 0;
for span in line_spans {
let span_width = span.content.width();
if current_width + span_width <= self.max_width {
current_spans.push(span);
current_width += span_width;
} else {
let available = self.max_width.saturating_sub(current_width);
if available > 0 {
let mut fit_len = 0;
let mut fit_width = 0;
for ch in span.content.chars() {
let ch_w = if ch > '\u{7F}' { 2 } else { 1 };
if fit_width + ch_w > available {
break;
}
fit_len += ch.len_utf8();
fit_width += ch_w;
}
if fit_len > 0 {
current_spans.push(Span::styled(
span.content[..fit_len].to_string(),
span.style,
));
self.lines.push(Line::from(current_spans.clone()));
current_spans.clear();
current_width = 0;
let remaining = &span.content[fit_len..];
if remaining.width() > self.max_width {
let mut rest = remaining;
while !rest.is_empty() {
let mut r_len = 0;
let mut r_width = 0;
for ch in rest.chars() {
let ch_w = if ch > '\u{7F}' { 2 } else { 1 };
if r_width + ch_w > self.max_width {
break;
}
r_len += ch.len_utf8();
r_width += ch_w;
}
if r_len > 0 {
self.lines.push(Line::from(vec![Span::styled(
rest[..r_len].to_string(),
span.style,
)]));
rest = &rest[r_len..];
} else {
break;
}
}
} else {
current_spans
.push(Span::styled(remaining.to_string(), span.style));
current_width = remaining.width();
}
}
} else {
if !current_spans.is_empty() {
self.lines.push(Line::from(current_spans.clone()));
current_spans.clear();
}
if span_width > self.max_width {
let content = &span.content;
let style = span.style;
let mut pos = 0;
while pos < content.len() {
let mut r_len = 0;
let mut r_width = 0;
for ch in content[pos..].chars() {
let ch_w = if ch > '\u{7F}' { 2 } else { 1 };
if r_width + ch_w > self.max_width {
break;
}
r_len += ch.len_utf8();
r_width += ch_w;
}
if r_len > 0 {
self.lines.push(Line::from(vec![Span::styled(
content[pos..pos + r_len].to_string(),
style,
)]));
pos += r_len;
} else {
break;
}
}
} else {
current_spans.push(span);
current_width = span_width;
}
}
}
}
if !current_spans.is_empty() {
self.lines.push(Line::from(current_spans));
}
}
}
}
fn highlight_code_with_colors(&self, lang: &str, code: &str) -> Vec<Vec<Span<'static>>> {
let ss = get_syntax_set();
let ts = get_theme_set();
let syntax = ss
.find_syntax_by_token(lang)
.or_else(|| ss.find_syntax_by_extension(lang))
.unwrap_or_else(|| ss.find_syntax_plain_text());
let theme = ts
.themes
.get("base16-eighties.dark")
.or_else(|| ts.themes.get("Solarized (dark)"))
.or_else(|| ts.themes.get("base16-mono.dark"))
.or_else(|| ts.themes.values().next())
.expect("theme set should always have at least one theme");
use syntect::easy::HighlightLines;
let mut highlighter = HighlightLines::new(syntax, theme);
code.lines()
.map(|line| {
let highlighted = highlighter.highlight_line(line, ss).unwrap_or_default();
highlighted
.iter()
.map(|(style, text)| {
let fg = syntect_color_to_ratatui(style.foreground);
Span::styled(text.to_string(), Style::default().fg(fg))
})
.collect()
})
.collect()
}
fn render_table(&mut self) {
if self.table_header.is_empty() {
return;
}
let mut widths: Vec<usize> = self.table_header.iter().map(|c| c.width() + 2).collect();
for row in &self.table_rows {
for (i, cell) in row.iter().enumerate() {
if i < widths.len() {
widths[i] = widths[i].max(cell.width() + 2);
}
}
}
let top_border = format!(
"┌{}┐",
widths
.iter()
.map(|w| "─".repeat(*w))
.collect::<Vec<_>>()
.join("┬")
);
let row_sep = format!(
"├{}┤",
widths
.iter()
.map(|w| "─".repeat(*w))
.collect::<Vec<_>>()
.join("┼")
);
let bottom_border = format!(
"└{}┘",
widths
.iter()
.map(|w| "─".repeat(*w))
.collect::<Vec<_>>()
.join("┴")
);
self.lines.push(Line::styled(
top_border,
Style::default().fg(Color::DarkGray),
));
let header_line = format!(
"│{}│",
widths
.iter()
.enumerate()
.map(|(i, w)| self.pad_cell(&self.table_header[i], *w))
.collect::<Vec<_>>()
.join("│")
);
self.lines.push(Line::styled(
header_line,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
));
self.lines.push(Line::styled(
row_sep.clone(),
Style::default().fg(Color::DarkGray),
));
for (idx, row) in self.table_rows.iter().enumerate() {
let row_line = format!(
"│{}│",
widths
.iter()
.enumerate()
.map(|(i, w)| self.pad_cell(&row[i], *w))
.collect::<Vec<_>>()
.join("│")
);
self.lines
.push(Line::styled(row_line, Style::default().fg(Color::Gray)));
if idx < self.table_rows.len() - 1 {
self.lines.push(Line::styled(
row_sep.clone(),
Style::default().fg(Color::DarkGray),
));
}
}
self.lines.push(Line::styled(
bottom_border,
Style::default().fg(Color::DarkGray),
));
}
fn pad_cell(&self, content: &str, width: usize) -> String {
let content_width = content.width();
let padding = width.saturating_sub(content_width);
let left_pad = padding / 2;
let right_pad = padding - left_pad;
format!(
"{}{}{}",
" ".repeat(left_pad),
content,
" ".repeat(right_pad)
)
}
}
fn syntect_color_to_ratatui(c: syntect::highlighting::Color) -> Color {
let r = c.r;
let g = c.g;
let b = c.b;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
if max - min < 20 {
if r < 80 {
return Color::DarkGray;
}
if r < 160 {
return Color::Gray;
}
return Color::White;
}
if r >= g && r >= b && r > 150 {
if g > 100 && b < 100 {
return Color::Yellow;
}
if b > 100 && g < 100 {
return Color::Magenta;
}
return Color::Red;
}
if g >= r && g >= b && g > 150 {
if b > 100 && r < 100 {
return Color::Cyan;
}
return Color::Green;
}
if b >= r && b >= g && b > 150 {
return Color::Blue;
}
Color::Rgb(r, g, b)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plain_text() {
let result = render_markdown("Hello world", 80);
assert!(!result.is_empty());
let text = result[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
assert!(text.contains("Hello"));
}
#[test]
fn test_heading() {
let result = render_markdown("# Title", 80);
assert!(!result.is_empty());
let text = result[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
assert!(text.contains("Title"));
}
#[test]
fn test_table() {
let md = "| A | B |\n|---|---|\n| 1 | 2 |";
let result = render_markdown(md, 80);
assert!(!result.is_empty());
}
#[test]
fn test_math() {
let md = "Inline: $E=mc^2$";
let result = render_markdown(md, 80);
assert!(!result.is_empty());
let text = result[0]
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
assert!(text.contains("$E=mc^2$"));
}
}
#[cfg(test)]
mod debug_tests {
use super::*;
#[test]
fn debug_table() {
let md = "| A | B |\n|---|---|\n| 1 | 2 |";
println!("\n=== Simple Table ===");
let lines = render_markdown(md, 60);
for (i, line) in lines.iter().enumerate() {
let text = line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
println!("[{}] '{}'", i, text);
}
}
#[test]
fn debug_table_chinese() {
let md = "| 名称 | 数值 |\n|------|------|\n| 测试 | 123 |\n| 数据 | 456 |";
println!("\n=== Chinese Table ===");
let lines = render_markdown(md, 80);
for (i, line) in lines.iter().enumerate() {
let text = line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
println!("[{}] '{}'", i, text);
}
}
#[test]
fn debug_table_multi_row() {
let md = "| Col1 | Col2 | Col3 |\n|------|------|------|\n| A1 | B1 | C1 |\n| A2 | B2 | C2 |\n| A3 | B3 | C3 |";
println!("\n=== Multi-row Table ===");
let lines = render_markdown(md, 80);
for (i, line) in lines.iter().enumerate() {
let text = line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
println!("[{}] '{}'", i, text);
}
}
#[test]
fn debug_table_complex() {
let md = "| Name | Description | Link |\n|------|-------------|------|\n| `code` | This has **bold** text | [click](url) |\n| item2 | normal text | none |";
println!("\n=== Complex Table ===");
let lines = render_markdown(md, 80);
for (i, line) in lines.iter().enumerate() {
let text = line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
println!("[{}] '{}'", i, text);
}
}
#[test]
fn debug_table_events() {
let md = "| A | B |\n|---|---|\n| 1 | 2 |";
println!("\n=== Table Events (pulldown-cmark 0.13) ===");
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
let parser = Parser::new_ext(md, options);
for (i, event) in parser.enumerate() {
println!("[{}] {:?}", i, event);
}
}
#[test]
fn debug_math() {
let md = "Inline: $E=mc^2$\n\nBlock:\n$$\\sum_{i=1}^n i$$";
println!("\n=== Math ===");
let lines = render_markdown(md, 80);
for (i, line) in lines.iter().enumerate() {
let text = line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>();
println!("[{}] '{}'", i, text);
}
}
}