use pulldown_cmark::{
Alignment, CodeBlockKind, Event, HeadingLevel, LinkType, Options, Parser, Tag, TagEnd,
};
use crate::cells::cell_len;
use crate::console::{Console, ConsoleOptions, Justify};
use crate::protocol::Renderable;
use crate::r#box::SIMPLE;
use crate::segment::Segment;
use crate::style::Style;
use crate::syntax::Syntax;
use crate::table::Table;
use crate::text::Text;
const CODE_STYLE: &str = "bold cyan on black"; const IMAGE_MARKER: &str = "\u{1f306} ";
const BULLET: &str = " \u{2022} "; const QUOTE_PREFIX: &str = "\u{258c} "; const LINK_STYLE: &str = "bright_blue"; const LINK_URL_STYLE: &str = "underline blue"; const TABLE_BORDER_STYLE: &str = "cyan"; const TABLE_HEADER_STYLE: &str = "not bold cyan";
struct ListEntry {
number: Option<u64>,
blocks: Vec<Block>,
}
enum Frame {
List {
ordered: bool,
start: u64,
entries: Vec<ListEntry>,
},
Item {
blocks: Vec<Block>,
},
Quote {
blocks: Vec<Block>,
},
}
enum Block {
Text(Text),
List { items: Vec<ListEntry> },
Quote(Vec<Block>),
Code { language: String, code: String },
Rule,
Image {
text: Text,
joins_next: bool,
leading_break: bool,
},
Table {
alignments: Vec<Justify>,
headers: Vec<String>,
rows: Vec<Vec<String>>,
},
}
#[derive(Default)]
struct TableAccum {
alignments: Vec<Justify>,
headers: Vec<String>,
rows: Vec<Vec<String>>,
in_head: bool,
in_cell: bool,
cur_row: Vec<String>,
cur_cell: String,
}
fn alignment_justify(alignment: Alignment) -> Justify {
match alignment {
Alignment::Right => Justify::Right,
Alignment::Center => Justify::Center,
Alignment::Left | Alignment::None => Justify::Left,
}
}
pub struct Markdown {
source: String,
hyperlinks: bool,
blocks: Vec<Block>,
}
impl Markdown {
pub fn new(source: &str) -> Self {
Markdown {
source: source.to_string(),
hyperlinks: true,
blocks: parse(source, true),
}
}
pub fn hyperlinks(mut self, hyperlinks: bool) -> Self {
if hyperlinks != self.hyperlinks {
self.blocks = parse(&self.source, hyperlinks);
self.hyperlinks = hyperlinks;
}
self
}
}
fn heading_level(level: HeadingLevel) -> usize {
match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
}
}
fn heading_format(level: usize) -> (Style, Justify) {
let (spec, justify) = match level {
1 => ("bold underline", Justify::Center),
2 => ("underline magenta", Justify::Left),
3 => ("bold magenta", Justify::Left),
4 => ("italic magenta", Justify::Left),
5 => ("italic", Justify::Left),
_ => ("dim", Justify::Left),
};
(Style::parse(spec).unwrap_or_default(), justify)
}
fn inline_style(strong: usize, emphasis: usize, strike: usize) -> Option<Style> {
if strong == 0 && emphasis == 0 && strike == 0 {
return None;
}
let mut style = Style::new();
if strong > 0 {
style = style.combine(&Style::parse("bold").expect("valid style"));
}
if emphasis > 0 {
style = style.combine(&Style::parse("italic").expect("valid style"));
}
if strike > 0 {
style = style.combine(&Style::parse("strike").expect("valid style"));
}
Some(style)
}
fn link_style(url: &str) -> Style {
Style::parse(LINK_URL_STYLE)
.expect("valid style")
.with_link(url.to_string())
}
fn stack_style(
heading: Option<&Style>,
inline: Option<Style>,
link: Option<&str>,
extra: Option<Style>,
) -> Option<Style> {
let mut current: Option<Style> = None;
for layer in [heading.cloned(), inline, link.map(link_style), extra] {
let Some(next) = layer else { continue };
current = Some(match current {
Some(previous) => previous.combine(&next),
None => next,
});
}
current
}
fn image_fallback_title(destination: &str) -> &str {
let trimmed = destination.trim_matches('/');
match trimmed.rsplit_once('/') {
Some((_, last)) => last,
None => trimmed,
}
}
fn image_text(
destination: &str,
alt: Text,
link: Option<&str>,
outer: Option<Style>,
hyperlinks: bool,
) -> Text {
let mut title = if alt.plain().is_empty() {
Text::new(image_fallback_title(destination))
} else {
alt
};
let end = title.plain().len();
if let Some(style) = outer {
title.stylize(style, 0, end);
}
if hyperlinks {
let target = link.unwrap_or(destination);
if !target.is_empty() {
title.stylize(Style::new().with_link(target.to_string()), 0, end);
}
}
let mut text = Text::new(IMAGE_MARKER).append_text(&title);
text.append(" ", None);
text
}
fn sink<'a>(document: &'a mut Vec<Block>, stack: &'a mut [Frame]) -> &'a mut Vec<Block> {
match stack
.iter()
.rposition(|frame| matches!(frame, Frame::Item { .. } | Frame::Quote { .. }))
{
Some(index) => match &mut stack[index] {
Frame::Item { blocks } | Frame::Quote { blocks } => blocks,
Frame::List { .. } => unreachable!("rposition matched Item or Quote"),
},
None => document,
}
}
const MAX_NESTING: usize = 20;
fn flush_pending(current: &mut Option<Text>, blocks: &mut Vec<Block>, stack: &mut [Frame]) {
let Some(mut text) = current.take() else {
return;
};
if text.plain().is_empty() {
return;
}
text.set_justify(Justify::Left);
sink(blocks, stack).push(Block::Text(text));
}
fn push_tilde(current: &mut Option<Text>, link_label: &mut Option<String>) {
if let Some(label) = link_label.as_mut() {
label.push('~');
} else {
current
.get_or_insert_with(|| Text::new(""))
.append("~", None);
}
}
fn append_break(
current: Option<&mut Text>,
link_label: Option<&mut String>,
text: &str,
style: Option<Style>,
) {
if let Some(label) = link_label {
label.push_str(text);
} else if let Some(block) = current {
block.append(text, style.map(Into::into));
}
}
fn parse(source: &str, hyperlinks: bool) -> Vec<Block> {
let mut blocks: Vec<Block> = Vec::new();
let mut current: Option<Text> = None;
let mut heading_style: Option<Style> = None;
let mut justify = Justify::Left;
let mut strong = 0usize;
let mut emphasis = 0usize;
let mut strike = 0usize;
let mut single_tilde = 0usize;
let mut stack: Vec<Frame> = Vec::new();
let mut suppressed = 0usize;
let mut item_suppressed = 0usize;
let mut code: Option<(String, String)> = None;
let mut link: Option<String> = None;
let mut link_label: Option<String> = None;
let mut image: Option<String> = None;
let mut image_span: Option<(usize, usize)> = None;
let mut new_line = false;
let mut table: Option<TableAccum> = None;
let options = Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH;
for (event, range) in Parser::new_ext(source, options).into_offset_iter() {
if image.is_some() && !matches!(event, Event::End(TagEnd::Image)) {
image_span = Some(match image_span {
Some((start, end)) => (start.min(range.start), end.max(range.end)),
None => (range.start, range.end),
});
continue;
}
match &event {
Event::End(
TagEnd::Paragraph
| TagEnd::Heading(_)
| TagEnd::List(_)
| TagEnd::Item
| TagEnd::BlockQuote(_)
| TagEnd::CodeBlock
| TagEnd::Table
| TagEnd::TableHead
| TagEnd::TableRow
| TagEnd::TableCell,
) => new_line = true,
Event::Rule => new_line = false,
_ => {}
}
match event {
Event::Rule => {
flush_pending(&mut current, &mut blocks, &mut stack);
sink(&mut blocks, &mut stack).push(Block::Rule);
}
Event::Start(Tag::Link {
link_type,
dest_url,
..
}) => {
link = Some(match link_type {
LinkType::Email => format!("mailto:{dest_url}"),
_ => dest_url.to_string(),
});
if !hyperlinks {
link_label = Some(String::new());
}
}
Event::End(TagEnd::Link) => {
let url = link.take();
let label = link_label.take();
if let Some(url) = url.filter(|_| !hyperlinks) {
let label = label.unwrap_or_default();
let inline = inline_style(strong, emphasis, strike);
if let Some(acc) = table.as_mut().filter(|a| a.in_cell) {
acc.cur_cell.push_str(&label);
acc.cur_cell.push_str(" (");
acc.cur_cell.push_str(&url);
acc.cur_cell.push(')');
} else {
let block = current.get_or_insert_with(|| Text::new(""));
let layer = |style: Option<Style>| {
stack_style(heading_style.as_ref(), inline.clone(), None, style)
};
if !label.is_empty() {
block.append(
&label,
layer(Style::parse(LINK_STYLE).ok()).map(Into::into),
);
}
block.append(" (", layer(None).map(Into::into));
block.append(
&url,
layer(Style::parse(LINK_URL_STYLE).ok()).map(Into::into),
);
block.append(")", layer(None).map(Into::into));
}
}
}
Event::Start(Tag::Image { dest_url, .. })
if !table.as_ref().is_some_and(|acc| acc.in_cell) =>
{
image = Some(dest_url.to_string());
image_span = None;
}
Event::End(TagEnd::Image) => {
if let Some(destination) = image.take() {
let alt = image_span
.take()
.map(|(start, end)| Text::new(&source[start..end]))
.unwrap_or_default();
blocks.push(Block::Image {
text: image_text(
&destination,
alt,
link.as_deref(),
stack_style(
heading_style.as_ref(),
inline_style(strong, emphasis, strike),
link.as_deref().filter(|_| hyperlinks),
None,
),
hyperlinks,
),
joins_next: stack.is_empty(),
leading_break: new_line,
});
new_line = false;
}
}
Event::Start(Tag::CodeBlock(kind)) => {
flush_pending(&mut current, &mut blocks, &mut stack);
let language = match kind {
CodeBlockKind::Fenced(info) => {
info.split_whitespace().next().unwrap_or("").to_string()
}
CodeBlockKind::Indented => String::new(),
};
code = Some((language, String::new()));
}
Event::End(TagEnd::CodeBlock) => {
if let Some((language, mut source)) = code.take() {
if source.ends_with('\n') {
source.pop();
}
sink(&mut blocks, &mut stack).push(Block::Code {
language,
code: source,
});
}
}
Event::Start(Tag::Table(aligns)) => {
flush_pending(&mut current, &mut blocks, &mut stack);
table = Some(TableAccum {
alignments: aligns.into_iter().map(alignment_justify).collect(),
..TableAccum::default()
});
}
Event::End(TagEnd::Table) => {
if let Some(acc) = table.take() {
sink(&mut blocks, &mut stack).push(Block::Table {
alignments: acc.alignments,
headers: acc.headers,
rows: acc.rows,
});
}
}
Event::Start(Tag::TableHead) => {
if let Some(acc) = table.as_mut() {
acc.in_head = true;
acc.cur_row = Vec::new();
}
}
Event::End(TagEnd::TableHead) => {
if let Some(acc) = table.as_mut() {
acc.headers = std::mem::take(&mut acc.cur_row);
acc.in_head = false;
}
}
Event::Start(Tag::TableRow) => {
if let Some(acc) = table.as_mut() {
acc.cur_row = Vec::new();
}
}
Event::End(TagEnd::TableRow) => {
if let Some(acc) = table.as_mut() {
let row = std::mem::take(&mut acc.cur_row);
acc.rows.push(row);
}
}
Event::Start(Tag::TableCell) => {
if let Some(acc) = table.as_mut() {
acc.in_cell = true;
acc.cur_cell = String::new();
}
}
Event::End(TagEnd::TableCell) => {
if let Some(acc) = table.as_mut() {
let cell = std::mem::take(&mut acc.cur_cell);
acc.cur_row.push(cell);
acc.in_cell = false;
}
}
Event::Start(Tag::BlockQuote(_)) => {
flush_pending(&mut current, &mut blocks, &mut stack);
if stack.len() >= MAX_NESTING {
suppressed += 1;
} else {
stack.push(Frame::Quote { blocks: Vec::new() });
}
}
Event::End(TagEnd::BlockQuote(_)) => {
if suppressed > 0 {
suppressed -= 1;
} else if let Some(Frame::Quote { blocks: quoted }) = stack.pop() {
sink(&mut blocks, &mut stack).push(Block::Quote(quoted));
}
}
Event::Start(Tag::List(first)) => {
flush_pending(&mut current, &mut blocks, &mut stack);
if stack.len() >= MAX_NESTING {
suppressed += 1;
} else {
stack.push(Frame::List {
ordered: first.is_some(),
start: first.unwrap_or(1),
entries: Vec::new(),
});
}
}
Event::End(TagEnd::List(_)) => {
if suppressed > 0 {
suppressed -= 1;
} else if let Some(Frame::List { entries, .. }) = stack.pop() {
sink(&mut blocks, &mut stack).push(Block::List { items: entries });
}
}
Event::Start(Tag::Item) => {
if stack.len() >= MAX_NESTING {
item_suppressed += 1;
} else {
stack.push(Frame::Item { blocks: Vec::new() });
}
current = Some(Text::new(""));
heading_style = None;
justify = Justify::Left;
}
Event::End(TagEnd::Item) => {
if let Some(mut text) = current.take() {
text.set_justify(Justify::Left);
sink(&mut blocks, &mut stack).push(Block::Text(text));
}
if item_suppressed > 0 {
item_suppressed -= 1;
} else if let Some(Frame::Item {
blocks: item_blocks,
}) = stack.pop()
{
if let Some(Frame::List {
ordered,
start,
entries,
}) = stack.last_mut()
{
let number = ordered.then(|| *start + entries.len() as u64);
entries.push(ListEntry {
number,
blocks: item_blocks,
});
}
}
}
Event::Start(Tag::Paragraph) => {
flush_pending(&mut current, &mut blocks, &mut stack);
current = Some(Text::new(""));
heading_style = None;
justify = Justify::Left;
}
Event::Start(Tag::Heading { level, .. }) => {
flush_pending(&mut current, &mut blocks, &mut stack);
let (style, heading_justify) = heading_format(heading_level(level));
current = Some(Text::new(""));
heading_style = Some(style);
justify = heading_justify;
}
Event::End(TagEnd::Paragraph) | Event::End(TagEnd::Heading(_)) => {
if let Some(mut text) = current.take() {
let in_quote = stack
.iter()
.rposition(|f| matches!(f, Frame::Item { .. } | Frame::Quote { .. }))
.is_some_and(|i| matches!(stack[i], Frame::Quote { .. }));
if in_quote {
text.set_base_style(Style::parse("magenta").expect("valid style"));
}
text.set_justify(justify);
sink(&mut blocks, &mut stack).push(Block::Text(text));
}
heading_style = None;
justify = Justify::Left;
strong = 0;
emphasis = 0;
}
Event::Start(Tag::Strong) => strong += 1,
Event::End(TagEnd::Strong) => strong = strong.saturating_sub(1),
Event::Start(Tag::Strikethrough) => {
if source[range.clone()].starts_with("~~") {
strike += 1;
} else {
single_tilde += 1;
push_tilde(&mut current, &mut link_label);
}
}
Event::End(TagEnd::Strikethrough) => {
if single_tilde > 0 {
single_tilde -= 1;
push_tilde(&mut current, &mut link_label);
} else {
strike = strike.saturating_sub(1);
}
}
Event::Start(Tag::Emphasis) => emphasis += 1,
Event::End(TagEnd::Emphasis) => emphasis = emphasis.saturating_sub(1),
Event::Text(text) => {
if let Some(label) = link_label.as_mut() {
label.push_str(&text);
} else if let Some(acc) = table.as_mut().filter(|a| a.in_cell) {
acc.cur_cell.push_str(&text);
} else if let Some((_, source)) = code.as_mut() {
source.push_str(&text);
} else {
let block = current.get_or_insert_with(|| Text::new(""));
let style = stack_style(
heading_style.as_ref(),
inline_style(strong, emphasis, strike),
link.as_deref().filter(|_| hyperlinks),
None,
);
block.append(&text, style.map(Into::into));
}
}
Event::Code(text) => {
if let Some(label) = link_label.as_mut() {
label.push_str(&text);
} else if let Some(acc) = table.as_mut().filter(|a| a.in_cell) {
acc.cur_cell.push_str(&text);
} else {
let block = current.get_or_insert_with(|| Text::new(""));
let style = stack_style(
heading_style.as_ref(),
inline_style(strong, emphasis, strike),
link.as_deref().filter(|_| hyperlinks),
Style::parse(CODE_STYLE).ok(),
);
block.append(&text, style.map(Into::into));
}
}
Event::SoftBreak => append_break(
current.as_mut(),
link_label.as_mut(),
" ",
stack_style(
heading_style.as_ref(),
inline_style(strong, emphasis, strike),
link.as_deref().filter(|_| hyperlinks),
None,
),
),
Event::HardBreak => append_break(
current.as_mut(),
link_label.as_mut(),
"\n",
stack_style(
heading_style.as_ref(),
inline_style(strong, emphasis, strike),
link.as_deref().filter(|_| hyperlinks),
None,
),
),
_ => {}
}
}
blocks
}
impl Renderable for Markdown {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let mut lines = render_blocks(&self.blocks, console, options, options.max_width, true);
if matches!(self.blocks.last(), Some(Block::Rule)) {
lines.push(Vec::new());
}
let mut segments = Vec::new();
let last = lines.len().saturating_sub(1);
for (index, line) in lines.into_iter().enumerate() {
segments.extend(line);
if index != last {
segments.push(Segment::line());
}
}
segments
}
}
fn pad_lines(lines: &mut [Vec<Segment>], width: usize) {
for line in lines.iter_mut() {
let len: usize = line.iter().map(Segment::cell_length).sum();
if len < width {
line.push(Segment::new(" ".repeat(width - len), None));
}
}
}
fn render_blocks(
blocks: &[Block],
console: &Console,
options: &ConsoleOptions,
width: usize,
top_level: bool,
) -> Vec<Vec<Segment>> {
let base = console.base_style();
let mut lines: Vec<Vec<Segment>> = Vec::new();
let mut join_previous = false;
for (index, block) in blocks.iter().enumerate() {
let merge = std::mem::take(&mut join_previous);
let after_rule = index > 0 && matches!(blocks[index - 1], Block::Rule);
let own_gap = matches!(
block,
Block::List { .. } | Block::Quote(_) | Block::Table { .. }
);
let after_image = index > 0 && matches!(blocks[index - 1], Block::Image { .. });
let separator = match block {
Block::Image { leading_break, .. } => top_level && *leading_break,
_ if after_image => false,
_ => top_level && (own_gap || (index > 0 && !after_rule)),
};
if separator {
lines.push(Vec::new());
}
let start = lines.len();
match block {
Block::Text(text) => {
lines.extend(text.render_lines(console.theme(), base, Some(width)))
}
Block::Image {
text, joins_next, ..
} => {
lines.extend(text.render_lines(console.theme(), base, Some(width)));
join_previous = *joins_next;
}
Block::List { items } => {
for item in items {
let (prefix, prefix_style) = match item.number {
Some(number) => (
format!(" {number} "),
Style::parse("cyan").expect("valid style"),
),
None => (
BULLET.to_string(),
Style::parse("bold").expect("valid style"),
),
};
let prefix_width = cell_len(&prefix);
let item_lines = render_blocks(
&item.blocks,
console,
options,
width.saturating_sub(prefix_width),
false,
);
let mut item_lines: Vec<Vec<Segment>> = item_lines
.into_iter()
.skip_while(|line| line.is_empty())
.collect();
pad_lines(&mut item_lines, width.saturating_sub(prefix_width));
for (line_index, line) in item_lines.into_iter().enumerate() {
let mut row = Vec::new();
if line_index == 0 {
row.push(Segment::new(prefix.clone(), Some(prefix_style.clone())));
} else {
row.push(Segment::new(" ".repeat(prefix_width), None));
}
row.extend(line);
lines.push(row);
}
}
}
Block::Quote(quoted) => {
let prefix_style = Style::parse("magenta").expect("valid style");
let content_width = width.saturating_sub(4);
let quoted_lines = render_blocks(quoted, console, options, content_width, false);
let mut quoted_lines: Vec<Vec<Segment>> = quoted_lines
.into_iter()
.skip_while(|line| line.is_empty())
.collect();
pad_lines(&mut quoted_lines, content_width);
for line in quoted_lines {
let mut row = vec![Segment::new(
QUOTE_PREFIX.to_string(),
Some(prefix_style.clone()),
)];
row.extend(Segment::apply_style(&line, &prefix_style));
lines.push(row);
}
}
Block::Code { language, code } => {
let syntax = Syntax::new(code.as_str(), language.as_str())
.word_wrap(true)
.padding(1);
let inner = options.update_width(width);
let segments = syntax.rich_render(console, &inner);
lines.extend(Segment::split_lines(&segments));
}
Block::Rule => {
let style = Style::parse("dim").expect("valid style");
lines.push(vec![Segment::new("-".repeat(width), Some(style))]);
if index + 1 < blocks.len() || !top_level {
lines.push(Vec::new());
}
}
Block::Table {
alignments,
headers,
rows,
} => {
let mut table = Table::new()
.box_set(SIMPLE)
.pad_edge(false)
.collapse_padding(true)
.style(Style::parse(TABLE_BORDER_STYLE).expect("valid style"));
let header_style = Style::parse(TABLE_HEADER_STYLE).expect("valid style");
for (col, header) in headers.iter().enumerate() {
let justify = alignments.get(col).copied().unwrap_or(Justify::Left);
table.add_column_justify(header.as_str(), justify);
table.column_header_style(header_style.clone());
}
for row in rows {
let refs: Vec<&str> = row.iter().map(String::as_str).collect();
table.add_row(&refs);
}
let inner = options.update_width(width);
lines.extend(Segment::split_lines(&table.rich_render(console, &inner)));
}
}
if merge && lines.len() > start {
let first = lines.remove(start);
lines[start - 1].extend(first);
}
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::ColorSystem;
fn render(source: &str) -> String {
let console = Console::builder()
.force_terminal(true)
.color_system(Some(ColorSystem::Truecolor))
.width(20)
.build();
console.render_to_string(&Markdown::new(source))
}
#[test]
fn paragraph_inline_styles() {
assert_eq!(
render("a `x` b"),
"a \x1b[1;36;40mx\x1b[0m b "
);
}
#[test]
fn link_renders_osc8_hyperlink() {
let out = render("See [the site](https://example.com) now.");
assert!(
out.contains(
"\x1b]8;;https://example.com\x1b\\\x1b[4;34mthe site\x1b[0m\x1b]8;;\x1b\\"
),
"got {out:?}"
);
assert!(!out.contains("id="), "we omit the random link id");
}
#[test]
fn fenced_code_block_is_highlighted() {
let console = Console::builder()
.force_terminal(true)
.color_system(Some(ColorSystem::Truecolor))
.width(24)
.no_color(false)
.build();
let out = console.render_to_string(&Markdown::new("```rust\nfn main() {}\n```"));
assert!(out.contains("fn"), "got {out:?}");
assert!(out.contains("main"));
assert!(out.contains('\x1b'), "code block should be colored");
}
#[test]
fn headings() {
assert_eq!(render("# Head"), " \x1b[1;4mHead\x1b[0m ");
assert_eq!(render("## Sub"), "\x1b[4;35mSub\x1b[0m ");
}
#[test]
fn two_paragraphs_separated_by_blank_line() {
assert_eq!(
render("First para.\n\nSecond para."),
"First para. \n\nSecond para. "
);
}
#[test]
fn bullet_list() {
assert_eq!(
render("- one\n- two"),
"\n\x1b[1m \u{2022} \x1b[0mone \n\x1b[1m \u{2022} \x1b[0mtwo "
);
}
#[test]
fn ordered_list() {
assert_eq!(
render("1. first\n2. second"),
"\n\x1b[36m 1 \x1b[0mfirst \n\x1b[36m 2 \x1b[0msecond "
);
}
#[test]
fn block_quote() {
assert_eq!(
render("> quoted text"),
"\n\x1b[35m\u{258c} \x1b[0m\x1b[35mquoted text\x1b[0m\x1b[35m \x1b[0m"
);
}
#[test]
fn gfm_table() {
let console = Console::builder()
.force_terminal(true)
.color_system(Some(ColorSystem::Truecolor))
.width(40)
.no_color(false)
.build();
let md = "| Name | Age |\n| :--- | ---: |\n| Alice | 30 |\n| Bob | 7 |\n";
let out = console.render_to_string(&Markdown::new(md));
assert!(out.contains("Name"), "header present: {out:?}");
assert!(out.contains("Alice"), "body cell present");
assert!(out.contains('\u{2500}'), "SIMPLE box head rule present");
assert!(out.contains(" 30"), "right-justified 30");
assert!(out.contains(" 7"), "right-justified 7");
}
#[test]
fn thematic_break() {
assert_eq!(
render("a\n\n---\n\nb"),
"a \n\n\x1b[2m--------------------\x1b[0m\n\nb "
);
}
#[test]
fn thematic_break_at_end_adds_trailing_blank() {
assert_eq!(
render("a\n\n---"),
"a \n\n\x1b[2m--------------------\x1b[0m\n"
);
}
}
#[cfg(test)]
mod container_tests {
use super::*;
fn plain(source: &str, width: usize) -> String {
let console = Console::builder().width(width).no_color(true).build();
console.render_to_string(&Markdown::new(source))
}
fn assert_all_present(source: &str, expected: &[&str]) {
let out = plain(source, 44);
for item in expected {
assert!(out.contains(item), "{item:?} missing from:\n{out}");
}
}
#[test]
fn a_nested_list_keeps_every_item() {
assert_all_present("- one\n- two\n - nested\n", &["one", "two", "nested"]);
}
#[test]
fn nesting_three_deep_keeps_every_item() {
assert_all_present("- top\n - mid\n - deep\n", &["top", "mid", "deep"]);
}
#[test]
fn an_item_following_a_sublist_keeps_its_place() {
let out = plain("- one\n - nested\n- two\n", 44);
let (a, b, c) = (
out.find("one").expect("one"),
out.find("nested").expect("nested"),
out.find("two").expect("two"),
);
assert!(a < b && b < c, "order was wrong:\n{out}");
}
#[test]
fn each_level_of_an_ordered_list_numbers_independently() {
let out = plain("1. first\n2. second\n 1. sub\n", 44);
for expected in ["1 first", "2 second", "1 sub"] {
assert!(out.contains(expected), "expected {expected:?} in:\n{out}");
}
}
#[test]
fn nested_items_are_indented_under_their_parent() {
let out = plain("- top\n - child\n", 44);
let indent = |needle: &str| {
let line = out.lines().find(|l| l.contains(needle)).expect(needle);
line.len() - line.trim_start().len()
};
assert!(indent("child") > indent("top"), "not indented:\n{out}");
}
#[test]
fn a_heading_inside_an_item_keeps_the_item_text() {
assert_all_present(
"- ITEMTEXT\n\n ## HEADTEXT\n\n- NEXTTEXT\n",
&["ITEMTEXT", "HEADTEXT", "NEXTTEXT"],
);
}
#[test]
fn a_code_block_inside_an_item_stays_in_the_item() {
let out = plain("- FIRSTITEM\n\n ```\n CODETEXT\n ```\n", 44);
let (item, code) = (
out.find("FIRSTITEM").expect("item"),
out.find("CODETEXT").expect("code"),
);
assert!(item < code, "the code was hoisted above its item:\n{out}");
}
#[test]
fn two_paragraphs_in_one_item_stay_separate() {
let out = plain("- AAA\n\n BBB\n", 44);
assert!(!out.contains("AAABBB"), "paragraphs were fused:\n{out}");
assert!(out.contains("AAA") && out.contains("BBB"), "{out}");
}
#[test]
fn a_nested_quote_keeps_the_outer_text() {
assert_all_present(
"> OUTERTEXT\n>\n> > INNERTEXT\n",
&["OUTERTEXT", "INNERTEXT"],
);
}
#[test]
fn a_list_inside_a_quote_stays_quoted_and_in_order() {
let out = plain("> intro\n>\n> - item one\n> - item two\n", 44);
for line in out
.lines()
.filter(|l| l.contains("item one") || l.contains("intro"))
{
assert!(
line.trim_start().starts_with(QUOTE_PREFIX.trim_end()),
"lost the quote bar: {line:?}\n{out}"
);
}
let (intro, one) = (
out.find("intro").expect("intro"),
out.find("item one").expect("item one"),
);
assert!(intro < one, "quote content was reordered:\n{out}");
}
#[test]
fn a_quote_inside_an_item_stays_inside_it() {
let out = plain("- alpha\n\n > quoted\n", 44);
assert!(!out.contains("alphaquoted"), "fused:\n{out}");
let quoted = out.lines().find(|l| l.contains("quoted")).expect("quoted");
assert!(
quoted.contains(QUOTE_PREFIX.trim_end()),
"lost the quote bar:\n{out}"
);
}
#[test]
fn a_tight_item_keeps_its_text_before_a_heading() {
assert_all_present(
"- P1_text\n ## H1_head\n- P2_text\n",
&["P1_text", "H1_head", "P2_text"],
);
}
#[test]
fn a_tight_item_keeps_its_text_before_a_quote() {
assert_all_present("- Q1_text\n > Q1_quote\n", &["Q1_text", "Q1_quote"]);
}
#[test]
fn a_tight_ordered_item_keeps_its_text_before_a_quote() {
assert_all_present("1. C_num_text\n > C_quote\n", &["C_num_text", "C_quote"]);
}
#[test]
fn a_nested_tight_item_keeps_its_text_before_a_heading() {
assert_all_present(
"- A\n - B_inner\n ## B_head\n",
&["A", "B_inner", "B_head"],
);
}
#[test]
fn a_tight_code_block_renders_after_the_text_that_introduces_it() {
let out = plain("- F1_text\n ```\n F1_code\n ```\n- F2_text\n", 55);
let (text, code) = (
out.find("F1_text").expect("F1_text"),
out.find("F1_code").expect("F1_code"),
);
assert!(text < code, "the code block overtook its paragraph:\n{out}");
}
#[test]
fn deeply_nested_input_does_not_overflow_the_stack() {
for depth in [50usize, 400, 2000] {
let quotes = ">".repeat(depth) + " x\n";
let _ = plain("es, 80);
let list: String = (0..depth)
.map(|i| format!("{}- L{i}\n", " ".repeat(i)))
.collect();
let _ = plain(&list, 80);
}
}
#[test]
fn a_tight_item_keeps_text_that_follows_a_nested_block() {
assert_all_present(
"- ITEM\n ```\n FIRST code\n ```\n SECOND para\n",
&["ITEM", "FIRST code", "SECOND para"],
);
assert_all_present(
"- ITEM\n ## HEAD\n TAIL para\n",
&["ITEM", "HEAD", "TAIL para"],
);
assert_all_present("- ITEM\n ---\n TAIL para\n", &["ITEM", "TAIL para"]);
}
#[test]
fn a_heading_inside_a_quote_keeps_its_alignment() {
let out = plain("> # Heading in quote\n", 50);
let line = out
.lines()
.find(|l| l.contains("Heading in quote"))
.expect("heading line");
let after_bar = line.split(QUOTE_PREFIX.trim_end()).nth(1).expect("bar");
assert!(
after_bar.starts_with(" "),
"heading was left-aligned inside the quote: {line:?}"
);
}
#[test]
fn strikethrough_is_rendered_rather_than_leaked() {
let out = plain("~~Deprecated~~ text\n", 50);
assert!(!out.contains("~~"), "tildes leaked into output: {out:?}");
assert!(out.contains("Deprecated"), "content lost: {out:?}");
}
#[test]
fn nested_blocks_gain_no_phantom_blank_row() {
let out = plain("- a\n - b\n - c\n- d\n", 50);
let rows: Vec<&str> = out
.lines()
.map(str::trim_end)
.filter(|l| !l.is_empty())
.collect();
assert_eq!(
rows.len(),
4,
"expected exactly four content rows, got {rows:?}"
);
}
#[test]
fn nesting_does_not_narrow_each_level() {
let source = "> d1\n\n>> d2\n\n>>> d3\n\n>>>> d4\n";
let out = plain(source, 70);
let widths: Vec<usize> = out
.lines()
.filter(|l| {
l.contains("d1") || l.contains("d2") || l.contains("d3") || l.contains("d4")
})
.map(|l| l.chars().count())
.collect();
assert_eq!(widths.len(), 4, "expected one row per depth: {widths:?}");
assert!(
widths.iter().all(|w| *w == widths[0]),
"each nesting level lost width: {widths:?}"
);
}
#[test]
fn a_single_tilde_is_literal_text() {
let out = plain("a ~struck~ b and ~~gone~~ here", 60);
assert!(
out.contains("~struck~"),
"single tildes were eaten: {out:?}"
);
assert!(!out.contains("~~gone~~"), "double tildes leaked: {out:?}");
assert!(out.contains("gone"), "struck content lost: {out:?}");
}
#[test]
fn a_code_block_is_inset_by_one_cell() {
let out = plain("intro para\n\n```\nCODEWORD\n```\n", 40);
let rows: Vec<&str> = out.lines().collect();
let index = rows
.iter()
.position(|r| r.contains("CODEWORD"))
.expect("code row present");
assert!(
rows[index].starts_with(' '),
"no left gutter on the code row: {:?}",
rows[index]
);
assert!(
rows[index - 1].trim().is_empty(),
"no blank inset row above the code: {:?}",
rows[index - 1]
);
assert!(
rows.get(index + 1).is_some_and(|r| r.trim().is_empty()),
"no blank inset row below the code"
);
}
#[test]
fn a_rule_is_followed_by_exactly_one_blank_row() {
let out = plain("before\n\n---\n\nafter\n", 40);
let rows: Vec<&str> = out.lines().collect();
let rule = rows
.iter()
.position(|r| r.trim_end().ends_with('-') && r.trim().len() > 3)
.expect("rule row present");
let after = rows
.iter()
.position(|r| r.contains("after"))
.expect("following row present");
assert_eq!(
after - rule,
2,
"expected one blank row between rule and next block: {rows:?}"
);
}
#[test]
fn an_image_is_marked_and_hoisted() {
let row = |source: &str| {
plain(source, 40)
.lines()
.next()
.expect("a row")
.trim_end()
.to_string()
};
assert_eq!(
row(""),
"🌆 alt text"
);
assert_eq!(row(""), "🌆 pic.png");
assert_eq!(row(""), "🌆 img");
assert_eq!(
row("Before  after."),
"🌆 alt text Before after."
);
assert_eq!(row(""), "🌆 alt *em*");
}
#[test]
fn an_image_is_lifted_out_of_a_list_or_quote() {
let rows = |source: &str| -> Vec<String> {
plain(source, 40)
.lines()
.map(|line| line.trim_end().to_string())
.collect()
};
assert_eq!(
rows("- item with  inside"),
vec!["🌆 pic", " • item with inside"]
);
assert_eq!(
rows("> quoted  end"),
vec!["🌆 pic", "▌ quoted end"]
);
}
#[test]
fn a_long_code_line_keeps_its_tail() {
let source = "```bash\npip install some-package another-package \
yet-another-package --upgrade --no-cache-dir\n```\n";
let out = plain(source, 80);
assert!(
out.contains("no-cache-dir"),
"the tail of the code line was discarded: {out:?}"
);
}
#[test]
fn a_fenced_block_expands_its_tabs() {
let out = plain("```python\ndef f():\n\tif x:\n\t\treturn 1\n```", 30);
assert_eq!(
out.split('\n').collect::<Vec<_>>(),
[
" ",
" def f(): ",
" if x: ",
" return 1 ",
" ",
]
);
}
}
#[cfg(test)]
mod hyperlink_tests {
use super::*;
use crate::color::ColorSystem;
fn plain(source: &str, width: usize, hyperlinks: bool) -> String {
Console::builder()
.width(width)
.no_color(true)
.build()
.render_to_string(&Markdown::new(source).hyperlinks(hyperlinks))
}
fn ansi(source: &str, width: usize, hyperlinks: bool) -> String {
Console::builder()
.force_terminal(true)
.color_system(Some(ColorSystem::Truecolor))
.width(width)
.no_color(false)
.build()
.render_to_string(&Markdown::new(source).hyperlinks(hyperlinks))
}
#[test]
fn hyperlinks_off_writes_the_url_out_after_the_label() {
assert_eq!(
plain("A [link](https://example.com) here.", 40, false),
"A link (https://example.com) here. "
);
}
#[test]
fn hyperlinks_on_keeps_the_label_alone() {
assert_eq!(
plain("A [link](https://example.com) here.", 40, true),
"A link here. "
);
}
#[test]
fn hyperlinks_off_widens_a_table_column_to_fit_the_url() {
let source = "| T | W |\n| :-- | --: |\n| r | [repo](https://ex.org/a) |\n";
assert_eq!(
plain(source, 60, false).split('\n').collect::<Vec<_>>(),
[
"",
" ",
" T W ",
" ────────────────────────── ",
" r repo (https://ex.org/a) ",
" ",
]
);
assert_eq!(
plain(source, 60, true).split('\n').collect::<Vec<_>>(),
[
"",
" ",
" T W ",
" ─────── ",
" r repo ",
" "
]
);
}
#[test]
fn hyperlinks_off_flattens_the_labels_own_emphasis() {
assert_eq!(
plain("A [**b** and *i* l](https://e.org) t.", 60, false),
"A b and i l (https://e.org) t. "
);
}
#[test]
fn hyperlinks_off_styles_the_label_and_the_url_under_a_heading() {
assert_eq!(
ansi("## H [x](https://e.org)", 40, false),
"\x1b[4;35mH \x1b[0m\x1b[4;94mx\x1b[0m\x1b[4;35m (\x1b[0m\
\x1b[4;34mhttps://e.org\x1b[0m\x1b[4;35m)\x1b[0m "
);
assert_eq!(
ansi("## H [x](https://e.org)", 40, true),
"\x1b[4;35mH \x1b[0m\x1b]8;;https://e.org\x1b\\\x1b[4;34mx\x1b[0m\
\x1b]8;;\x1b\\ "
);
}
#[test]
fn a_link_inside_bold_stays_bold() {
assert_eq!(
ansi("x **b [l](https://e.org) b** y", 60, true),
"x \x1b[1mb \x1b[0m\x1b]8;;https://e.org\x1b\\\x1b[1;4;34ml\x1b[0m\
\x1b]8;;\x1b\\\x1b[1m b\x1b[0m y "
);
}
#[test]
fn a_link_labelled_with_inline_code_keeps_its_destination() {
assert_eq!(
ansi("A [`code`](https://e.org/x) tail.", 60, true),
"A \x1b]8;;https://e.org/x\x1b\\\x1b[1;4;36;40mcode\x1b[0m\x1b]8;;\x1b\\ \
tail. "
);
}
#[test]
fn an_email_autolink_keeps_its_mailto_scheme() {
assert_eq!(
plain("Mail <who@where.net> now.", 50, false),
"Mail who@where.net (mailto:who@where.net) now. "
);
assert_eq!(
ansi("Mail <who@where.net> now.", 50, true),
"Mail \x1b]8;;mailto:who@where.net\x1b\\\x1b[4;34mwho@where.net\x1b[0m\
\x1b]8;;\x1b\\ now. "
);
}
#[test]
fn an_image_inside_a_link_carries_the_links_style() {
assert_eq!(
ansi("[](https://e.org)", 40, true),
"\u{1f306} \x1b]8;;https://e.org\x1b\\\x1b[4;34mbadge\x1b[0m\
\x1b]8;;\x1b\\ "
);
}
#[test]
fn a_single_tilde_inside_a_link_label_keeps_its_place() {
let out = plain("A [~a~ label](https://e.com) here.\n", 60, false);
assert!(
out.contains("~a~ label"),
"tilde moved out of the label: {out:?}"
);
assert!(!out.contains("~~a"), "tildes were reordered: {out:?}");
}
#[test]
fn a_single_tilde_survives_with_no_buffer_open() {
let out = plain("~5~10 and ~x~\n", 40, false);
assert!(out.contains("~5~10"), "tilde dropped: {out:?}");
assert!(out.contains("~x~"), "tilde dropped: {out:?}");
}
}