#[cfg(feature = "markdown")]
use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
use crate::segment::{Segment, Segments};
use crate::style::Style;
#[derive(Debug, Clone)]
pub struct Markdown {
source: String,
code_theme: bool,
hyperlinks: bool,
inline_code_style: Option<Style>,
}
impl Markdown {
#[must_use]
#[inline]
pub fn new(source: impl Into<String>) -> Self {
Self {
source: source.into(),
code_theme: true,
hyperlinks: true,
inline_code_style: None,
}
}
#[must_use]
#[inline]
pub const fn code_theme(mut self, enabled: bool) -> Self {
self.code_theme = enabled;
self
}
#[must_use]
#[inline]
pub const fn hyperlinks(mut self, enabled: bool) -> Self {
self.hyperlinks = enabled;
self
}
#[must_use]
#[inline]
pub fn inline_code_style(mut self, style: Style) -> Self {
self.inline_code_style = Some(style);
self
}
#[must_use]
#[inline]
pub fn source(&self) -> &str {
&self.source
}
#[cfg(feature = "markdown")]
#[must_use]
pub fn render(&self, _max_width: usize) -> Segments {
let mut segments = Segments::new();
let parser = Parser::new(&self.source);
let mut in_heading = false;
let mut heading_level = 0;
let mut in_emphasis = false;
let mut in_strong = false;
let mut in_code = false;
let mut _in_link = false;
let mut link_url = String::new();
let mut _in_list = false;
let mut list_item_number: Option<u64> = None;
let mut in_blockquote = false;
for event in parser {
match event {
Event::Start(tag) => match tag {
Tag::Heading { level, .. } => {
in_heading = true;
heading_level = match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
};
}
Tag::Emphasis => in_emphasis = true,
Tag::Strong => in_strong = true,
Tag::CodeBlock(_) => {
in_code = true;
segments.push(Segment::newline());
}
Tag::Link { dest_url, .. } => {
_in_link = true;
link_url = dest_url.to_string();
}
Tag::List(start) => {
_in_list = true;
list_item_number = start;
}
Tag::Item => {
if let Some(ref mut n) = list_item_number {
segments.push(Segment::new(format!(" {}. ", n)));
*n = n.saturating_add(1);
} else {
segments.push(Segment::new(" • "));
}
}
Tag::BlockQuote => {
in_blockquote = true;
}
Tag::Paragraph => {}
_ => {}
},
Event::End(tag_end) => match tag_end {
TagEnd::Heading(_) => {
in_heading = false;
segments.push(Segment::newline());
if heading_level <= 2 {
let char = if heading_level == 1 { '═' } else { '─' };
segments.push(Segment::new(char.to_string().repeat(40)));
segments.push(Segment::newline());
}
}
TagEnd::Emphasis => in_emphasis = false,
TagEnd::Strong => in_strong = false,
TagEnd::CodeBlock => {
in_code = false;
segments.push(Segment::newline());
}
TagEnd::Link => {
if self.hyperlinks && !link_url.is_empty() {
let style = Style::default().underline();
segments.push(Segment::styled(format!(" ({})", link_url), style.dim()));
}
_in_link = false;
link_url.clear();
}
TagEnd::List(_) => {
_in_list = false;
list_item_number = None;
}
TagEnd::Item => {
segments.push(Segment::newline());
}
TagEnd::BlockQuote => {
in_blockquote = false;
}
TagEnd::Paragraph => {
segments.push(Segment::newline());
segments.push(Segment::newline());
}
_ => {}
},
Event::Text(text) => {
let content = text.to_string();
let mut style = Style::default();
if in_heading {
style = style.bold();
if heading_level == 1 {
}
}
if in_emphasis {
style = style.italic();
}
if in_strong {
style = style.bold();
}
if in_blockquote {
style = style.dim().italic();
segments.push(Segment::styled("│ ", style.clone()));
}
if in_code {
if self.code_theme {
style = style.dim();
}
let indented = content
.lines()
.map(|l| format!(" {}", l))
.collect::<Vec<_>>()
.join("\n");
segments.push(Segment::styled(indented, style));
} else if style.is_empty() {
segments.push(Segment::new(content));
} else {
segments.push(Segment::styled(content, style));
}
}
Event::Code(code) => {
let style = self
.inline_code_style
.clone()
.unwrap_or_else(|| Style::default().reverse());
segments.push(Segment::styled(format!(" {} ", code), style));
}
Event::SoftBreak | Event::HardBreak => {
segments.push(Segment::newline());
}
Event::Rule => {
segments.push(Segment::newline());
segments.push(Segment::new("─".repeat(40)));
segments.push(Segment::newline());
}
_ => {}
}
}
segments
}
#[cfg(not(feature = "markdown"))]
#[must_use]
pub fn render(&self, _max_width: usize) -> Segments {
let mut segments = Segments::new();
for line in self.source.lines() {
let line = if line.starts_with("# ") {
let text = line.trim_start_matches("# ");
segments.push(Segment::styled(text.to_string(), Style::default().bold()));
segments.push(Segment::newline());
segments.push(Segment::new("═".repeat(40)));
segments.push(Segment::newline());
continue;
} else if line.starts_with("## ") {
let text = line.trim_start_matches("## ");
segments.push(Segment::styled(text.to_string(), Style::default().bold()));
segments.push(Segment::newline());
segments.push(Segment::new("─".repeat(40)));
segments.push(Segment::newline());
continue;
} else if line.starts_with("- ") || line.starts_with("* ") {
let text = line.trim_start_matches("- ").trim_start_matches("* ");
format!(" • {}", text)
} else if line.starts_with("> ") {
let text = line.trim_start_matches("> ");
let style = Style::default().dim().italic();
segments.push(Segment::styled("│ ", style.clone()));
segments.push(Segment::styled(text.to_string(), style));
segments.push(Segment::newline());
continue;
} else if line == "---" || line == "***" {
segments.push(Segment::new("─".repeat(40)));
segments.push(Segment::newline());
continue;
} else {
line.to_string()
};
let processed = process_inline_markdown(&line);
segments.extend(processed.into_iter());
segments.push(Segment::newline());
}
segments
}
}
#[cfg(not(feature = "markdown"))]
fn process_inline_markdown(text: &str) -> Segments {
let mut segments = Segments::new();
let mut current = String::new();
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '*' && chars.peek() == Some(&'*') {
if !current.is_empty() {
segments.push(Segment::new(std::mem::take(&mut current)));
}
chars.next(); let mut bold_text = String::new();
while let Some(c) = chars.next() {
if c == '*' && chars.peek() == Some(&'*') {
chars.next();
break;
}
bold_text.push(c);
}
segments.push(Segment::styled(bold_text, Style::default().bold()));
} else if ch == '*' || ch == '_' {
if !current.is_empty() {
segments.push(Segment::new(std::mem::take(&mut current)));
}
let mut italic_text = String::new();
for c in chars.by_ref() {
if c == ch {
break;
}
italic_text.push(c);
}
segments.push(Segment::styled(italic_text, Style::default().italic()));
} else if ch == '`' {
if !current.is_empty() {
segments.push(Segment::new(std::mem::take(&mut current)));
}
let mut code_text = String::new();
for c in chars.by_ref() {
if c == '`' {
break;
}
code_text.push(c);
}
segments.push(Segment::styled(
format!(" {} ", code_text),
Style::default().reverse(),
));
} else {
current.push(ch);
}
}
if !current.is_empty() {
segments.push(Segment::new(current));
}
segments
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_markdown_new() {
let md = Markdown::new("# Hello");
assert_eq!(md.source(), "# Hello");
}
#[test]
fn test_markdown_code_theme() {
let md = Markdown::new("text").code_theme(false);
assert!(!md.code_theme);
}
#[test]
fn test_markdown_hyperlinks() {
let md = Markdown::new("text").hyperlinks(false);
assert!(!md.hyperlinks);
}
#[test]
fn test_markdown_inline_code_style() {
let style = Style::default().bold();
let md = Markdown::new("text").inline_code_style(style);
assert!(md.inline_code_style.is_some());
}
#[test]
fn test_markdown_render_heading() {
let md = Markdown::new("# Hello\n\nWorld");
let segments = md.render(80);
let output = segments.to_ansi();
assert!(output.contains("Hello"));
assert!(output.contains("World"));
}
#[test]
fn test_markdown_render_list() {
let md = Markdown::new("- Item 1\n- Item 2");
let segments = md.render(80);
let output = segments.to_ansi();
assert!(output.contains("Item 1"));
assert!(output.contains("Item 2"));
}
#[test]
fn test_markdown_render_code() {
let md = Markdown::new("Use `code` here");
let segments = md.render(80);
let output = segments.to_ansi();
assert!(output.contains("code"));
}
}