use pulldown_cmark::{Event as MarkdownEvent, Options, Parser, Tag, TagEnd};
use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
};
use syntect::easy::HighlightLines;
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
pub struct MarkdownRenderer;
impl MarkdownRenderer {
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn render_markdown(content: &str, width: usize) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(content, options);
let mut current_line = Vec::new();
let mut in_code_block = false;
let mut code_block_lang = String::new();
let mut code_block_content = String::new();
let mut list_level = 0;
let mut current_style = Style::default();
for event in parser {
match event {
MarkdownEvent::Start(Tag::CodeBlock(kind)) => {
Self::flush_line(&mut lines, &mut current_line);
in_code_block = true;
if let pulldown_cmark::CodeBlockKind::Fenced(lang) = kind {
code_block_lang = lang.into_string();
}
}
MarkdownEvent::End(TagEnd::CodeBlock) => {
in_code_block = false;
let highlighted = Self::highlight_code(&code_block_content, &code_block_lang);
lines.extend(highlighted);
code_block_content.clear();
code_block_lang.clear();
}
MarkdownEvent::Text(text) => {
if in_code_block {
code_block_content.push_str(&text);
} else {
Self::add_text_to_line(
&mut lines,
&mut current_line,
&text,
width,
list_level,
current_style,
);
}
}
MarkdownEvent::SoftBreak => {
if !in_code_block {
Self::add_text_to_line(
&mut lines,
&mut current_line,
" ",
width,
list_level,
current_style,
);
}
}
MarkdownEvent::HardBreak => {
if !in_code_block {
Self::flush_line(&mut lines, &mut current_line);
}
}
MarkdownEvent::Start(Tag::List(..)) => {
Self::flush_line(&mut lines, &mut current_line);
list_level += 1;
}
MarkdownEvent::End(TagEnd::List(..)) => {
Self::flush_line(&mut lines, &mut current_line);
list_level = list_level.saturating_sub(1);
}
MarkdownEvent::Start(Tag::Item) => {
Self::flush_line(&mut lines, &mut current_line);
let bullet = if list_level % 2 == 1 { "• " } else { "◦ " };
current_line.push(Span::raw(" ".repeat(list_level - 1) + bullet));
}
MarkdownEvent::End(TagEnd::Item) => {
Self::flush_line(&mut lines, &mut current_line);
}
MarkdownEvent::Start(Tag::Emphasis) => {
current_style = current_style.add_modifier(Modifier::ITALIC);
}
MarkdownEvent::End(TagEnd::Emphasis) => {
current_style = current_style.remove_modifier(Modifier::ITALIC);
}
MarkdownEvent::Start(Tag::Strong) => {
current_style = current_style.add_modifier(Modifier::BOLD);
}
MarkdownEvent::End(TagEnd::Strong) => {
current_style = current_style.remove_modifier(Modifier::BOLD);
}
MarkdownEvent::Code(text) => {
let code_style = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::ITALIC);
let code_span = format!("`{text}`");
Self::add_text_to_line(
&mut lines,
&mut current_line,
&code_span,
width,
list_level,
code_style,
);
}
MarkdownEvent::Start(Tag::Paragraph) => {
if !lines.is_empty() {
lines.push(Line::default());
}
}
MarkdownEvent::End(TagEnd::Paragraph) => {
Self::flush_line(&mut lines, &mut current_line);
lines.push(Line::default());
}
_ => {}
}
}
Self::flush_line(&mut lines, &mut current_line);
while lines.last().map_or(false, |line| line.spans.is_empty()) {
lines.pop();
}
lines
}
fn add_text_to_line(
lines: &mut Vec<Line<'static>>,
current_line: &mut Vec<Span<'static>>,
text: &str,
width: usize,
list_level: usize,
style: Style,
) {
let indent = if list_level > 0 { 2 * list_level } else { 0 };
let available_width = width.saturating_sub(indent);
if current_line.is_empty() && list_level > 0 {
current_line.push(Span::raw(" ".repeat(indent)));
}
let mut remaining_text = text;
while !remaining_text.is_empty() {
let current_line_width = current_line
.iter()
.map(|span| span.content.width())
.sum::<usize>();
let space_left = available_width.saturating_sub(current_line_width);
if space_left == 0 {
Self::flush_line(lines, current_line);
if list_level > 0 {
current_line.push(Span::raw(" ".repeat(indent)));
}
continue;
}
let (chunk, rest) = Self::split_at_width(remaining_text, space_left);
if !chunk.is_empty() {
if !current_line.is_empty() && !current_line.last().unwrap().content.ends_with(' ')
{
current_line.push(Span::raw(" "));
}
current_line.push(Span::styled(chunk.trim_end().to_string(), style));
}
remaining_text = rest;
if !remaining_text.is_empty() {
Self::flush_line(lines, current_line);
if list_level > 0 {
current_line.push(Span::raw(" ".repeat(indent)));
}
}
}
}
fn split_at_width(text: &str, width: usize) -> (&str, &str) {
let mut total_width = 0;
let mut split_index = text.len();
for (idx, c) in text.char_indices() {
let char_width = c.width_cjk().unwrap_or(1);
if total_width + char_width > width {
split_index = idx;
break;
}
total_width += char_width;
}
text.split_at(split_index)
}
fn flush_line(lines: &mut Vec<Line<'static>>, current_line: &mut Vec<Span<'static>>) {
if !current_line.is_empty() {
lines.push(Line::from(std::mem::take(current_line)));
}
}
fn highlight_code(code: &str, lang: &str) -> Vec<Line<'static>> {
let theme_set = ThemeSet::load_defaults();
let syntax_set = SyntaxSet::load_defaults_newlines();
let syntax = syntax_set
.find_syntax_by_extension(lang)
.unwrap_or_else(|| syntax_set.find_syntax_plain_text());
let mut h = HighlightLines::new(syntax, &theme_set.themes["base16-ocean.dark"]);
LinesWithEndings::from(code)
.map(|line| {
let highlighted = h.highlight_line(line, &syntax_set).unwrap();
let spans: Vec<Span> = highlighted
.into_iter()
.map(|(style, content)| {
let color =
Color::Rgb(style.foreground.r, style.foreground.g, style.foreground.b);
Span::styled(content.to_string(), Style::default().fg(color))
})
.collect();
Line::from(spans)
})
.collect()
}
}