use colored::*;
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
pub fn render_markdown_to_terminal(markdown: &str) {
let parser = Parser::new_ext(markdown, Options::all());
let events: Vec<Event> = parser.collect();
render_events(&events);
}
fn render_events(events: &[Event]) {
let mut i = 0;
let mut in_code_block = false;
let mut in_list = false;
let mut list_item_number = 0;
let mut in_ordered_list = false;
while i < events.len() {
match &events[i] {
Event::Start(Tag::Heading { level, .. }) => {
let heading_level = *level;
i += 1;
let text = extract_text_until_end(&events[i..], TagEnd::Heading(heading_level));
println!(); match heading_level {
HeadingLevel::H1 => {
println!("{}", text.bold().bright_blue().underline());
println!("{}", "=".repeat(text.len()).bright_blue());
}
HeadingLevel::H2 => {
println!("{}", text.bold().bright_cyan());
println!("{}", "-".repeat(text.len()).bright_cyan());
}
HeadingLevel::H3 => {
println!("{}", format!("### {text}").bold().green());
}
HeadingLevel::H4 => {
println!("{}", format!("#### {text}").bold().yellow());
}
HeadingLevel::H5 => {
println!("{}", format!("##### {text}").yellow());
}
HeadingLevel::H6 => {
println!("{}", format!("###### {text}").dimmed());
}
}
println!();
while i < events.len() {
if matches!(&events[i], Event::End(TagEnd::Heading(_))) {
break;
}
i += 1;
}
}
Event::Start(Tag::Paragraph) => {
i += 1;
let text = extract_styled_text_until_end(&events[i..], TagEnd::Paragraph);
print!("{text}");
println!();
println!();
while i < events.len() {
if matches!(&events[i], Event::End(TagEnd::Paragraph)) {
break;
}
i += 1;
}
}
Event::Start(Tag::List(first_number)) => {
in_list = true;
in_ordered_list = first_number.is_some();
list_item_number = first_number.unwrap_or(0) as usize;
println!(); }
Event::End(TagEnd::List(_)) => {
in_list = false;
println!(); }
Event::Start(Tag::Item) => {
i += 1;
let text = extract_styled_text_until_end(&events[i..], TagEnd::Item);
if in_ordered_list {
print!(" {}. ", list_item_number.to_string().bright_yellow());
list_item_number += 1;
} else {
print!(" {} ", "•".bright_green());
}
println!("{text}");
while i < events.len() {
if matches!(&events[i], Event::End(TagEnd::Item)) {
break;
}
i += 1;
}
}
Event::Start(Tag::CodeBlock(kind)) => {
in_code_block = true;
i += 1;
let lang = match kind {
pulldown_cmark::CodeBlockKind::Fenced(lang) => lang.to_string(),
_ => String::new(),
};
let code = extract_text_until_end(&events[i..], TagEnd::CodeBlock);
println!(); if !lang.is_empty() {
println!("{}", format!("```{lang}").dimmed());
} else {
println!("{}", "```".dimmed());
}
for line in code.lines() {
println!("{}", line.on_truecolor(40, 44, 52));
}
println!("{}", "```".dimmed());
println!();
while i < events.len() {
if matches!(&events[i], Event::End(TagEnd::CodeBlock)) {
in_code_block = true;
break;
}
i += 1;
}
}
Event::Code(code) => {
print!("{}", code.bright_magenta().on_truecolor(50, 50, 50));
}
Event::Start(Tag::Emphasis) => {
i += 1;
let text = extract_text_until_end(&events[i..], TagEnd::Emphasis);
print!("{}", text.italic());
while i < events.len() {
if matches!(&events[i], Event::End(TagEnd::Emphasis)) {
break;
}
i += 1;
}
}
Event::Start(Tag::Strong) => {
i += 1;
let text = extract_text_until_end(&events[i..], TagEnd::Strong);
print!("{}", text.bold());
while i < events.len() {
if matches!(&events[i], Event::End(TagEnd::Strong)) {
break;
}
i += 1;
}
}
Event::Start(Tag::BlockQuote(_)) => {
i += 1;
let mut blockquote_text = String::new();
while i < events.len() {
match &events[i] {
Event::Text(text) => blockquote_text.push_str(text),
Event::End(TagEnd::BlockQuote(_)) => break,
Event::SoftBreak | Event::HardBreak => blockquote_text.push('\n'),
_ => {}
}
i += 1;
}
println!();
for line in blockquote_text.lines() {
println!("{} {}", "│".bright_black(), line.italic().dimmed());
}
println!();
}
Event::Start(Tag::Link { dest_url, .. }) => {
i += 1;
let text = extract_text_until_end(&events[i..], TagEnd::Link);
print!("{}", text.bright_blue().underline());
print!("{}", format!(" ({dest_url})").dimmed());
while i < events.len() {
if matches!(&events[i], Event::End(TagEnd::Link)) {
break;
}
i += 1;
}
}
Event::Rule => {
println!();
println!("{}", "─".repeat(80).bright_black());
println!();
}
Event::Text(text) => {
if !in_code_block && !in_list {
print!("{text}");
}
}
Event::SoftBreak => {
print!(" ");
}
Event::HardBreak => {
println!();
}
_ => {}
}
i += 1;
}
}
fn extract_text_until_end(events: &[Event], end_tag: TagEnd) -> String {
let mut text = String::new();
for event in events {
match event {
Event::Text(t) => text.push_str(t),
Event::Code(t) => text.push_str(t),
Event::End(tag) if *tag == end_tag => break,
Event::SoftBreak | Event::HardBreak => text.push(' '),
_ => {}
}
}
text.trim().to_string()
}
fn extract_styled_text_until_end(events: &[Event], end_tag: TagEnd) -> String {
let mut result = String::new();
let mut i = 0;
while i < events.len() {
match &events[i] {
Event::Text(text) => {
result.push_str(text);
}
Event::Code(code) => {
result.push_str(&format!("{}", code.bright_magenta()));
}
Event::Start(Tag::Strong) => {
i += 1;
let text = extract_text_until_end(&events[i..], TagEnd::Strong);
result.push_str(&format!("{}", text.bold()));
while i < events.len() {
if matches!(&events[i], Event::End(TagEnd::Strong)) {
break;
}
i += 1;
}
}
Event::Start(Tag::Emphasis) => {
i += 1;
let text = extract_text_until_end(&events[i..], TagEnd::Emphasis);
result.push_str(&format!("{}", text.italic()));
while i < events.len() {
if matches!(&events[i], Event::End(TagEnd::Emphasis)) {
break;
}
i += 1;
}
}
Event::End(tag) if *tag == end_tag => break,
Event::SoftBreak => result.push(' '),
Event::HardBreak => result.push('\n'),
_ => {}
}
i += 1;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_markdown_rendering() {
let markdown = r#"
# Heading 1
## Heading 2
This is **bold** and this is *italic*.
- Item 1
- Item 2
- Item 3
1. First
2. Second
3. Third
```rust
fn main() {
println!("Hello, world!");
}
```
> This is a blockquote
`inline code`
[Link](https://example.com)
---
"#;
render_markdown_to_terminal(markdown);
}
}