use pulldown_cmark::{Alignment, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use crate::box_chars::HEAVY;
use crate::console::{Console, ConsoleOptions, Renderable};
use crate::panel::Panel;
use crate::rule::Rule;
use crate::segment::Segment;
use crate::style::{Style, StyleStack};
use crate::table::Table;
use crate::text::{JustifyMethod, Text};
#[derive(Debug, Clone)]
pub struct Markdown {
pub markup: String,
pub code_theme: String,
pub inline_code_lexer: Option<String>,
pub inline_code_theme: Option<String>,
pub hyperlinks: bool,
pub justify: Option<JustifyMethod>,
}
impl Markdown {
pub fn new(markup: &str) -> Self {
Markdown {
markup: markup.to_string(),
code_theme: "monokai".to_string(),
inline_code_lexer: None,
inline_code_theme: None,
hyperlinks: true,
justify: None,
}
}
#[must_use]
pub fn with_code_theme(mut self, theme: &str) -> Self {
self.code_theme = theme.to_string();
self
}
#[must_use]
pub fn with_hyperlinks(mut self, hyperlinks: bool) -> Self {
self.hyperlinks = hyperlinks;
self
}
#[must_use]
pub fn with_justify(mut self, justify: JustifyMethod) -> Self {
self.justify = Some(justify);
self
}
}
#[derive(Debug, Clone)]
struct ListContext {
ordered: bool,
item_number: u64,
}
#[derive(Debug, Clone)]
struct TableContext {
alignments: Vec<Alignment>,
header_cells: Vec<String>,
current_row: Vec<String>,
rows: Vec<Vec<String>>,
in_head: bool,
}
impl TableContext {
fn new() -> Self {
TableContext {
alignments: Vec::new(),
header_cells: Vec::new(),
current_row: Vec::new(),
rows: Vec::new(),
in_head: false,
}
}
}
impl Renderable for Markdown {
fn rich_console(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let mut segments: Vec<Segment> = Vec::new();
let width = options.max_width;
let base_style = Style::null();
let mut style_stack = StyleStack::new(base_style);
let mut text_buffer = Text::new("", Style::null());
let mut list_stack: Vec<ListContext> = Vec::new();
let mut blockquote_depth: usize = 0;
let mut link_url: Option<String> = None;
let mut code_block_text: Option<String> = None;
let mut table_ctx: Option<TableContext> = None;
let mut in_table_cell = false;
let mut cell_text = String::new();
let mut needs_newline = false;
let mut md_options = Options::empty();
md_options.insert(Options::ENABLE_TABLES);
md_options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(&self.markup, md_options);
let events: Vec<Event> = parser.collect();
for event in events {
match event {
Event::Start(Tag::Heading { .. }) => {
text_buffer = Text::new("", Style::null());
}
Event::End(TagEnd::Heading(level)) => {
let style_name = match level {
HeadingLevel::H1 => "markdown.h1",
HeadingLevel::H2 => "markdown.h2",
HeadingLevel::H3 => "markdown.h3",
HeadingLevel::H4 => "markdown.h4",
HeadingLevel::H5 => "markdown.h5",
HeadingLevel::H6 => "markdown.h6",
};
let heading_style = console
.get_style(style_name)
.unwrap_or_else(|_| Style::null());
if needs_newline {
segments.push(Segment::line());
}
let text_len = text_buffer.len();
if text_len > 0 {
text_buffer.stylize(heading_style.clone(), 0, Some(text_len));
}
text_buffer.end = String::new();
let heading_opts =
options.update_width(width.saturating_sub(blockquote_depth * 4));
let heading_segs = text_buffer.rich_console(console, &heading_opts);
segments.extend(heading_segs);
segments.push(Segment::line());
if matches!(level, HeadingLevel::H1 | HeadingLevel::H2) {
let rule_style = console
.get_style("markdown.hr")
.unwrap_or_else(|_| Style::null());
let rule = Rule::new().style(rule_style).end("");
let rule_segs = rule.rich_console(console, options);
segments.extend(rule_segs);
segments.push(Segment::line());
}
needs_newline = true;
text_buffer = Text::new("", Style::null());
}
Event::Start(Tag::Paragraph) => {
text_buffer = Text::new("", Style::null());
if let Some(j) = self.justify {
text_buffer.justify = Some(j);
}
}
Event::End(TagEnd::Paragraph) => {
if in_table_cell {
cell_text.push_str(text_buffer.plain());
text_buffer = Text::new("", Style::null());
continue;
}
if needs_newline {
segments.push(Segment::line());
}
let effective_width = width.saturating_sub(blockquote_depth * 4);
let para_opts = options.update_width(effective_width);
if blockquote_depth > 0 {
let bq_style = console
.get_style("markdown.block_quote")
.unwrap_or_else(|_| Style::null());
let indent: String =
std::iter::repeat_n(' ', blockquote_depth.saturating_sub(1) * 4)
.collect();
let bq_prefix = format!("{}\u{2502} ", indent);
let text_segs = text_buffer.rich_console(console, ¶_opts);
let rendered_text: String =
text_segs.iter().map(|s| s.text.as_str()).collect();
for line in rendered_text.lines() {
segments.push(Segment::styled(&bq_prefix, bq_style.clone()));
segments.push(Segment::text(line));
segments.push(Segment::line());
}
if rendered_text.trim().is_empty() {
segments.push(Segment::styled(&bq_prefix, bq_style.clone()));
segments.push(Segment::line());
}
} else {
let text_segs = text_buffer.rich_console(console, ¶_opts);
segments.extend(text_segs);
}
needs_newline = true;
text_buffer = Text::new("", Style::null());
}
Event::Start(Tag::Emphasis) => {
let em_style = console
.get_style("markdown.em")
.unwrap_or_else(|_| Style::parse("italic").unwrap());
style_stack.push(em_style);
}
Event::End(TagEnd::Emphasis) => {
let _ = style_stack.pop();
}
Event::Start(Tag::Strong) => {
let strong_style = console
.get_style("markdown.strong")
.unwrap_or_else(|_| Style::parse("bold").unwrap());
style_stack.push(strong_style);
}
Event::End(TagEnd::Strong) => {
let _ = style_stack.pop();
}
Event::Start(Tag::Strikethrough) => {
let s_style = console
.get_style("markdown.s")
.unwrap_or_else(|_| Style::parse("strike").unwrap());
style_stack.push(s_style);
}
Event::End(TagEnd::Strikethrough) => {
let _ = style_stack.pop();
}
Event::Code(text) => {
let code_style = console
.get_style("markdown.code")
.unwrap_or_else(|_| Style::parse("bold cyan on black").unwrap());
let current = style_stack.current().clone();
let combined = current + code_style;
text_buffer.append_str(&text, Some(combined));
}
Event::Start(Tag::Link { dest_url, .. }) => {
let link_style = console
.get_style("markdown.link")
.unwrap_or_else(|_| Style::parse("bright_blue").unwrap());
style_stack.push(link_style);
link_url = Some(dest_url.to_string());
}
Event::End(TagEnd::Link) => {
let _ = style_stack.pop();
if self.hyperlinks {
if let Some(ref url) = link_url {
let url_style = console
.get_style("markdown.link_url")
.unwrap_or_else(|_| Style::parse("underline blue").unwrap());
text_buffer.append_str(" (", None);
text_buffer.append_str(url, Some(url_style));
text_buffer.append_str(")", None);
}
}
link_url = None;
}
Event::Start(Tag::Image { dest_url, .. }) => {
let link_style = console
.get_style("markdown.link")
.unwrap_or_else(|_| Style::parse("bright_blue").unwrap());
style_stack.push(link_style);
link_url = Some(dest_url.to_string());
}
Event::End(TagEnd::Image) => {
let _ = style_stack.pop();
if self.hyperlinks {
if let Some(ref url) = link_url {
let url_style = console
.get_style("markdown.link_url")
.unwrap_or_else(|_| Style::parse("underline blue").unwrap());
text_buffer.append_str(" (", None);
text_buffer.append_str(url, Some(url_style));
text_buffer.append_str(")", None);
}
}
link_url = None;
}
Event::Start(Tag::CodeBlock(_kind)) => {
code_block_text = Some(String::new());
}
Event::End(TagEnd::CodeBlock) => {
if let Some(code_text) = code_block_text.take() {
let code_style = console
.get_style("markdown.code_block")
.unwrap_or_else(|_| Style::parse("cyan on black").unwrap());
if needs_newline {
segments.push(Segment::line());
}
let trimmed = code_text.trim_end_matches('\n');
let code_content = Text::styled(trimmed, code_style.clone());
let panel = Panel::new(code_content)
.box_chars(&HEAVY)
.style(code_style)
.expand(true);
let panel_segs = panel.rich_console(console, options);
segments.extend(panel_segs);
needs_newline = true;
}
}
Event::Start(Tag::List(first_item)) => match first_item {
Some(start_num) => {
list_stack.push(ListContext {
ordered: true,
item_number: start_num,
});
}
None => {
list_stack.push(ListContext {
ordered: false,
item_number: 0,
});
}
},
Event::End(TagEnd::List(_ordered)) => {
list_stack.pop();
if list_stack.is_empty() {
needs_newline = true;
}
}
Event::Start(Tag::Item) => {
text_buffer = Text::new("", Style::null());
}
Event::End(TagEnd::Item) => {
if needs_newline && list_stack.len() <= 1 {
segments.push(Segment::line());
}
let indent_level = list_stack.len().saturating_sub(1);
let indent: String = std::iter::repeat_n(' ', indent_level * 4).collect();
if let Some(ctx) = list_stack.last_mut() {
if ctx.ordered {
let num_style = console
.get_style("markdown.item.number")
.unwrap_or_else(|_| Style::parse("cyan").unwrap());
let prefix = format!("{}{}. ", indent, ctx.item_number);
segments.push(Segment::styled(&prefix, num_style));
ctx.item_number += 1;
} else {
let bullet_style = console
.get_style("markdown.item.bullet")
.unwrap_or_else(|_| Style::parse("bold").unwrap());
let prefix = format!("{}\u{2022} ", indent);
segments.push(Segment::styled(&prefix, bullet_style));
}
}
let item_width =
width.saturating_sub((list_stack.len().saturating_sub(1)) * 4 + 3);
let item_opts = options.update_width(item_width);
let item_segs = text_buffer.rich_console(console, &item_opts);
segments.extend(item_segs);
text_buffer = Text::new("", Style::null());
needs_newline = false;
}
Event::Start(Tag::BlockQuote(_kind)) => {
blockquote_depth += 1;
}
Event::End(TagEnd::BlockQuote(_kind)) => {
blockquote_depth = blockquote_depth.saturating_sub(1);
}
Event::Start(Tag::Table(alignments)) => {
let mut ctx = TableContext::new();
ctx.alignments = alignments.to_vec();
table_ctx = Some(ctx);
}
Event::End(TagEnd::Table) => {
if let Some(ctx) = table_ctx.take() {
if needs_newline {
segments.push(Segment::line());
}
let table_segs = render_table(console, options, &ctx);
segments.extend(table_segs);
needs_newline = true;
}
}
Event::Start(Tag::TableHead) => {
if let Some(ref mut ctx) = table_ctx {
ctx.in_head = true;
}
}
Event::End(TagEnd::TableHead) => {
if let Some(ref mut ctx) = table_ctx {
if !ctx.current_row.is_empty() {
ctx.header_cells = ctx.current_row.clone();
ctx.current_row.clear();
}
ctx.in_head = false;
}
}
Event::Start(Tag::TableRow) => {
if let Some(ref mut ctx) = table_ctx {
ctx.current_row.clear();
}
}
Event::End(TagEnd::TableRow) => {
if let Some(ref mut ctx) = table_ctx {
let row = ctx.current_row.clone();
if ctx.in_head {
ctx.header_cells = row;
} else {
ctx.rows.push(row);
}
}
}
Event::Start(Tag::TableCell) => {
in_table_cell = true;
cell_text.clear();
text_buffer = Text::new("", Style::null());
}
Event::End(TagEnd::TableCell) => {
let remaining = text_buffer.plain().to_string();
if !remaining.is_empty() {
cell_text.push_str(&remaining);
}
if let Some(ref mut ctx) = table_ctx {
ctx.current_row.push(cell_text.clone());
}
in_table_cell = false;
cell_text.clear();
text_buffer = Text::new("", Style::null());
}
Event::Rule => {
if needs_newline {
segments.push(Segment::line());
}
let hr_style = console
.get_style("markdown.hr")
.unwrap_or_else(|_| Style::parse("dim").unwrap());
let rule = Rule::new().style(hr_style).end("");
let rule_segs = rule.rich_console(console, options);
segments.extend(rule_segs);
segments.push(Segment::line());
needs_newline = true;
}
Event::Text(text) => {
if let Some(ref mut code_text) = code_block_text {
code_text.push_str(&text);
continue;
}
if in_table_cell {
cell_text.push_str(&text);
continue;
}
let current_style = style_stack.current().clone();
if current_style.is_null() {
text_buffer.append_str(&text, None);
} else {
text_buffer.append_str(&text, Some(current_style));
}
}
Event::SoftBreak => {
if code_block_text.is_some() {
if let Some(ref mut code_text) = code_block_text {
code_text.push('\n');
}
} else if in_table_cell {
cell_text.push(' ');
} else {
text_buffer.append_str(" ", None);
}
}
Event::HardBreak => {
if code_block_text.is_some() {
if let Some(ref mut code_text) = code_block_text {
code_text.push('\n');
}
} else if in_table_cell {
cell_text.push(' ');
} else {
text_buffer.append_str("\n", None);
}
}
Event::Html(_) | Event::InlineHtml(_) => {}
_ => {}
}
}
if !text_buffer.plain().is_empty() {
text_buffer.end = String::new();
let final_segs = text_buffer.rich_console(console, options);
segments.extend(final_segs);
segments.push(Segment::line());
}
segments
}
}
fn render_table(console: &Console, options: &ConsoleOptions, ctx: &TableContext) -> Vec<Segment> {
let headers: Vec<&str> = ctx.header_cells.iter().map(|s| s.as_str()).collect();
let mut table = Table::new(&headers);
for (i, alignment) in ctx.alignments.iter().enumerate() {
if i < table.columns.len() {
table.columns[i].justify = match alignment {
Alignment::None | Alignment::Left => JustifyMethod::Left,
Alignment::Center => JustifyMethod::Center,
Alignment::Right => JustifyMethod::Right,
};
}
}
let border_style_name = "markdown.table.border";
table.border_style = border_style_name.to_string();
let header_style_name = "markdown.table.header";
table.header_style = header_style_name.to_string();
for row in &ctx.rows {
let cells: Vec<&str> = row.iter().map(|s| s.as_str()).collect();
table.add_row(&cells);
}
table.rich_console(console, options)
}
impl std::fmt::Display for Markdown {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut console = Console::builder()
.width(f.width().unwrap_or(80))
.force_terminal(true)
.no_color(true)
.build();
console.begin_capture();
console.print(self);
let output = console.end_capture();
write!(f, "{}", output.trim_end_matches('\n'))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cells::cell_len;
fn make_console(width: usize) -> Console {
Console::builder()
.width(width)
.force_terminal(true)
.no_color(true)
.markup(false)
.build()
}
fn render_markdown(console: &Console, md: &Markdown) -> String {
let opts = console.options();
let segments = md.rich_console(console, &opts);
segments.iter().map(|s| s.text.as_str()).collect()
}
fn render_segments(console: &Console, md: &Markdown) -> Vec<Segment> {
let opts = console.options();
md.rich_console(console, &opts)
}
#[test]
fn test_simple_paragraph() {
let console = make_console(80);
let md = Markdown::new("Hello, world!");
let output = render_markdown(&console, &md);
assert!(output.contains("Hello, world!"));
}
#[test]
fn test_two_paragraphs() {
let console = make_console(80);
let md = Markdown::new("First paragraph.\n\nSecond paragraph.");
let output = render_markdown(&console, &md);
assert!(output.contains("First paragraph."));
assert!(output.contains("Second paragraph."));
}
#[test]
fn test_heading_h1() {
let console = make_console(80);
let md = Markdown::new("# Heading 1");
let output = render_markdown(&console, &md);
assert!(output.contains("Heading 1"));
}
#[test]
fn test_heading_h2() {
let console = make_console(80);
let md = Markdown::new("## Heading 2");
let output = render_markdown(&console, &md);
assert!(output.contains("Heading 2"));
}
#[test]
fn test_heading_h3() {
let console = make_console(80);
let md = Markdown::new("### Heading 3");
let output = render_markdown(&console, &md);
assert!(output.contains("Heading 3"));
}
#[test]
fn test_heading_h4() {
let console = make_console(80);
let md = Markdown::new("#### Heading 4");
let output = render_markdown(&console, &md);
assert!(output.contains("Heading 4"));
}
#[test]
fn test_heading_h5() {
let console = make_console(80);
let md = Markdown::new("##### Heading 5");
let output = render_markdown(&console, &md);
assert!(output.contains("Heading 5"));
}
#[test]
fn test_heading_h6() {
let console = make_console(80);
let md = Markdown::new("###### Heading 6");
let output = render_markdown(&console, &md);
assert!(output.contains("Heading 6"));
}
#[test]
fn test_headings_have_appropriate_styles() {
let console = make_console(80);
let md = Markdown::new("# Title");
let output = render_markdown(&console, &md);
assert!(output.contains("Title"));
assert!(output.contains('\u{2501}') || output.contains('-'));
}
#[test]
fn test_h1_has_rule_underline() {
let console = make_console(40);
let md = Markdown::new("# Big Title");
let segments = render_segments(&console, &md);
let text: String = segments.iter().map(|s| s.text.as_str()).collect();
assert!(text.contains("Big Title"));
assert!(text.contains('\u{2501}'));
}
#[test]
fn test_h2_has_rule_underline() {
let console = make_console(40);
let md = Markdown::new("## Sub Title");
let segments = render_segments(&console, &md);
let text: String = segments.iter().map(|s| s.text.as_str()).collect();
assert!(text.contains("Sub Title"));
assert!(text.contains('\u{2501}'));
}
#[test]
fn test_bold_text() {
let console = make_console(80);
let md = Markdown::new("This is **bold** text.");
let segments = render_segments(&console, &md);
let text: String = segments.iter().map(|s| s.text.as_str()).collect();
assert!(text.contains("bold"));
assert!(text.contains("This is"));
assert!(text.contains("text."));
let bold_seg = segments.iter().find(|s| s.text == "bold");
assert!(bold_seg.is_some(), "Should have a segment with text 'bold'");
if let Some(seg) = bold_seg {
assert!(seg.style.is_some(), "Bold segment should have a style");
}
}
#[test]
fn test_italic_text() {
let console = make_console(80);
let md = Markdown::new("This is *italic* text.");
let segments = render_segments(&console, &md);
let text: String = segments.iter().map(|s| s.text.as_str()).collect();
assert!(text.contains("italic"));
let italic_seg = segments.iter().find(|s| s.text == "italic");
assert!(
italic_seg.is_some(),
"Should have a segment with text 'italic'"
);
if let Some(seg) = italic_seg {
assert!(seg.style.is_some(), "Italic segment should have a style");
}
}
#[test]
fn test_bold_italic_combined() {
let console = make_console(80);
let md = Markdown::new("This is ***bold and italic*** text.");
let segments = render_segments(&console, &md);
let text: String = segments.iter().map(|s| s.text.as_str()).collect();
assert!(text.contains("bold and italic"));
let combined_seg = segments.iter().find(|s| s.text.contains("bold and italic"));
assert!(combined_seg.is_some());
if let Some(seg) = combined_seg {
assert!(
seg.style.is_some(),
"Bold+italic segment should have a style"
);
}
}
#[test]
fn test_inline_code() {
let console = make_console(80);
let md = Markdown::new("Use `println!` to print.");
let segments = render_segments(&console, &md);
let text: String = segments.iter().map(|s| s.text.as_str()).collect();
assert!(text.contains("println!"));
let code_seg = segments.iter().find(|s| s.text == "println!");
assert!(
code_seg.is_some(),
"Should have a segment with inline code text"
);
if let Some(seg) = code_seg {
assert!(seg.style.is_some(), "Inline code should have a style");
}
}
#[test]
fn test_code_block() {
let console = make_console(80);
let md = Markdown::new("```\nfn main() {\n println!(\"hello\");\n}\n```");
let output = render_markdown(&console, &md);
assert!(output.contains("fn main()"));
assert!(output.contains("println!"));
}
#[test]
fn test_code_block_with_language() {
let console = make_console(80);
let md = Markdown::new("```rust\nlet x = 42;\n```");
let output = render_markdown(&console, &md);
assert!(output.contains("let x = 42;"));
}
#[test]
fn test_link_with_url() {
let console = make_console(80);
let md = Markdown::new("[Rust](https://www.rust-lang.org)");
let output = render_markdown(&console, &md);
assert!(output.contains("Rust"));
assert!(output.contains("https://www.rust-lang.org"));
}
#[test]
fn test_link_without_url_display() {
let console = make_console(80);
let md = Markdown::new("[Rust](https://www.rust-lang.org)").with_hyperlinks(false);
let output = render_markdown(&console, &md);
assert!(output.contains("Rust"));
assert!(!output.contains("https://www.rust-lang.org"));
}
#[test]
fn test_unordered_list() {
let console = make_console(80);
let md = Markdown::new("- Item 1\n- Item 2\n- Item 3");
let output = render_markdown(&console, &md);
assert!(output.contains("Item 1"));
assert!(output.contains("Item 2"));
assert!(output.contains("Item 3"));
assert!(output.contains('\u{2022}'));
}
#[test]
fn test_ordered_list() {
let console = make_console(80);
let md = Markdown::new("1. First\n2. Second\n3. Third");
let output = render_markdown(&console, &md);
assert!(output.contains("First"));
assert!(output.contains("Second"));
assert!(output.contains("Third"));
assert!(output.contains("1."));
assert!(output.contains("2."));
assert!(output.contains("3."));
}
#[test]
fn test_nested_list() {
let console = make_console(80);
let md = Markdown::new("- Outer\n - Inner 1\n - Inner 2\n- Outer 2");
let output = render_markdown(&console, &md);
assert!(output.contains("Outer"));
assert!(output.contains("Inner 1"));
assert!(output.contains("Inner 2"));
assert!(output.contains("Outer 2"));
}
#[test]
fn test_block_quote() {
let console = make_console(80);
let md = Markdown::new("> This is a quote.");
let output = render_markdown(&console, &md);
assert!(output.contains("This is a quote."));
assert!(output.contains('\u{2502}'));
}
#[test]
fn test_horizontal_rule() {
let console = make_console(40);
let md = Markdown::new("Above\n\n---\n\nBelow");
let output = render_markdown(&console, &md);
assert!(output.contains("Above"));
assert!(output.contains("Below"));
assert!(output.contains('\u{2501}'));
}
#[test]
fn test_mixed_content() {
let console = make_console(80);
let md = Markdown::new("# Title\n\nA paragraph.\n\n- Item 1\n- Item 2\n\n```\ncode\n```");
let output = render_markdown(&console, &md);
assert!(output.contains("Title"));
assert!(output.contains("A paragraph."));
assert!(output.contains("Item 1"));
assert!(output.contains("Item 2"));
assert!(output.contains("code"));
}
#[test]
fn test_empty_markdown() {
let console = make_console(80);
let md = Markdown::new("");
let output = render_markdown(&console, &md);
assert!(output.is_empty() || output.trim().is_empty());
}
#[test]
fn test_table() {
let console = make_console(80);
let md = Markdown::new("| Name | Age |\n|------|-----|\n| Alice | 30 |\n| Bob | 25 |");
let output = render_markdown(&console, &md);
assert!(output.contains("Name"));
assert!(output.contains("Age"));
assert!(output.contains("Alice"));
assert!(output.contains("30"));
assert!(output.contains("Bob"));
assert!(output.contains("25"));
}
#[test]
fn test_table_with_alignment() {
let console = make_console(80);
let md =
Markdown::new("| Left | Center | Right |\n|:-----|:------:|------:|\n| L | C | R |");
let output = render_markdown(&console, &md);
assert!(output.contains("Left"));
assert!(output.contains("Center"));
assert!(output.contains("Right"));
}
#[test]
fn test_renderable_integration() {
let console = Console::builder()
.width(60)
.force_terminal(true)
.no_color(true)
.markup(false)
.build();
let md = Markdown::new("Hello, **world**!");
let opts = console.options();
let segments = md.rich_console(&console, &opts);
assert!(!segments.is_empty());
let text: String = segments.iter().map(|s| s.text.as_str()).collect();
assert!(text.contains("Hello,"));
assert!(text.contains("world"));
}
#[test]
fn test_renderable_through_console_render() {
let console = Console::builder()
.width(60)
.force_terminal(true)
.no_color(true)
.markup(false)
.build();
let md = Markdown::new("# Title\n\nParagraph text.");
let segments = console.render(&md, None);
let text: String = segments.iter().map(|s| s.text.as_str()).collect();
assert!(text.contains("Title"));
assert!(text.contains("Paragraph text."));
}
#[test]
fn test_constructor_defaults() {
let md = Markdown::new("test");
assert_eq!(md.markup, "test");
assert_eq!(md.code_theme, "monokai");
assert!(md.inline_code_lexer.is_none());
assert!(md.inline_code_theme.is_none());
assert!(md.hyperlinks);
assert!(md.justify.is_none());
}
#[test]
fn test_builder_code_theme() {
let md = Markdown::new("test").with_code_theme("dracula");
assert_eq!(md.code_theme, "dracula");
}
#[test]
fn test_builder_hyperlinks() {
let md = Markdown::new("test").with_hyperlinks(false);
assert!(!md.hyperlinks);
}
#[test]
fn test_builder_justify() {
let md = Markdown::new("test").with_justify(JustifyMethod::Center);
assert_eq!(md.justify, Some(JustifyMethod::Center));
}
#[test]
fn test_strikethrough() {
let console = make_console(80);
let md = Markdown::new("This is ~~deleted~~ text.");
let segments = render_segments(&console, &md);
let text: String = segments.iter().map(|s| s.text.as_str()).collect();
assert!(text.contains("deleted"));
}
#[test]
fn test_soft_break() {
let console = make_console(80);
let md = Markdown::new("Line one\nLine two");
let output = render_markdown(&console, &md);
assert!(output.contains("Line one"));
assert!(output.contains("Line two"));
}
#[test]
fn test_hard_break() {
let console = make_console(80);
let md = Markdown::new("Line one \nLine two");
let output = render_markdown(&console, &md);
assert!(output.contains("Line one"));
assert!(output.contains("Line two"));
}
#[test]
fn test_all_heading_levels() {
let console = make_console(80);
let md = Markdown::new("# H1\n\n## H2\n\n### H3\n\n#### H4\n\n##### H5\n\n###### H6");
let output = render_markdown(&console, &md);
assert!(output.contains("H1"));
assert!(output.contains("H2"));
assert!(output.contains("H3"));
assert!(output.contains("H4"));
assert!(output.contains("H5"));
assert!(output.contains("H6"));
}
#[test]
fn test_narrow_width() {
let console = make_console(20);
let md = Markdown::new("This is a paragraph with enough text to wrap.");
let output = render_markdown(&console, &md);
assert!(output.contains("This"));
for line in output.split('\n') {
if !line.is_empty() {
assert!(
cell_len(line) <= 20,
"Line exceeds width: '{}' ({} cells)",
line,
cell_len(line)
);
}
}
}
#[test]
fn test_code_block_has_panel_border() {
let console = make_console(40);
let md = Markdown::new("```\nhello\n```");
let output = render_markdown(&console, &md);
assert!(
output.contains('\u{2501}')
|| output.contains('\u{2503}')
|| output.contains('\u{250F}'),
"Code block should be wrapped in a panel border"
);
}
#[test]
fn test_list_with_inline_formatting() {
let console = make_console(80);
let md = Markdown::new("- **Bold item**\n- *Italic item*\n- `Code item`");
let output = render_markdown(&console, &md);
assert!(output.contains("Bold item"));
assert!(output.contains("Italic item"));
assert!(output.contains("Code item"));
}
#[test]
fn test_whitespace_only() {
let console = make_console(80);
let md = Markdown::new(" \n\n ");
let output = render_markdown(&console, &md);
assert!(output.trim().is_empty());
}
#[test]
fn test_blockquote_multiple_paragraphs() {
let console = make_console(80);
let md = Markdown::new("> First quote.\n>\n> Second quote.");
let output = render_markdown(&console, &md);
assert!(output.contains("First quote."));
assert!(output.contains("Second quote."));
}
#[test]
fn test_image() {
let console = make_console(80);
let md = Markdown::new("");
let output = render_markdown(&console, &md);
assert!(output.contains("Alt text"));
assert!(output.contains("https://example.com/image.png"));
}
#[test]
fn test_output_has_trailing_content() {
let console = make_console(80);
let md = Markdown::new("Hello");
let segments = render_segments(&console, &md);
assert!(!segments.is_empty());
}
#[test]
fn test_display_trait() {
let md = Markdown::new("# Hello\n\nWorld");
let s = format!("{}", md);
assert!(!s.is_empty());
}
}