use pulldown_cmark::{
Alignment, CodeBlockKind, CowStr, Event, HeadingLevel, LinkType, Options, Parser, Tag, TagEnd,
};
use crate::cells::cell_len;
use crate::console::{Console, ConsoleOptions, Justify};
use crate::markdown_url::{normalize_link, normalize_link_text, validate_link};
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 QUOTE_STYLE: &str = "magenta"; 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 {
blocks: Vec<Block>,
leading_break: bool,
},
Html,
Code {
language: String,
code: String,
theme: Option<String>,
},
Rule,
Image {
text: Text,
joins_next: bool,
leading_break: bool,
},
Table {
alignments: Vec<Justify>,
headers: Vec<Text>,
rows: Vec<Vec<Text>>,
},
}
#[derive(Default)]
struct TableAccum {
alignments: Vec<Justify>,
headers: Vec<Text>,
rows: Vec<Vec<Text>>,
in_head: bool,
in_cell: bool,
cur_row: Vec<Text>,
cur_cell: Text,
}
fn inline_target<'a>(
current: &'a mut Option<Text>,
table: &'a mut Option<TableAccum>,
) -> &'a mut Text {
match table.as_mut().filter(|acc| acc.in_cell) {
Some(acc) => &mut acc.cur_cell,
None => current.get_or_insert_with(|| Text::new("")),
}
}
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,
options: MarkdownOptions,
blocks: Vec<Block>,
}
#[derive(Clone, Default)]
struct MarkdownOptions {
no_hyperlinks: bool,
justify: Option<Justify>,
style: Option<Style>,
code_theme: Option<String>,
inline_code_lexer: Option<String>,
inline_code_theme: Option<String>,
}
impl Markdown {
pub fn new(source: &str) -> Self {
let options = MarkdownOptions::default();
Markdown {
source: source.to_string(),
blocks: parse(source, &options),
options,
}
}
pub fn hyperlinks(mut self, hyperlinks: bool) -> Self {
self.options.no_hyperlinks = !hyperlinks;
self.reparse()
}
pub fn justify(mut self, justify: Justify) -> Self {
self.options.justify = Some(justify);
self.reparse()
}
pub fn style(mut self, style: Style) -> Self {
self.options.style = Some(style).filter(|style| !style.is_null());
self.reparse()
}
pub fn code_theme(mut self, theme: impl Into<String>) -> Self {
self.options.code_theme = Some(theme.into());
self.reparse()
}
pub fn inline_code_lexer(mut self, lexer: impl Into<String>) -> Self {
self.options.inline_code_lexer = Some(lexer.into());
self.reparse()
}
pub fn inline_code_theme(mut self, theme: impl Into<String>) -> Self {
self.options.inline_code_theme = Some(theme.into());
self.reparse()
}
fn reparse(mut self) -> Self {
self.blocks = parse(&self.source, &self.options);
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 quote_root(md: &MarkdownOptions, stack: &[Frame]) -> Option<Style> {
let mut root = md.style.clone();
if stack
.iter()
.any(|frame| matches!(frame, Frame::Quote { .. }))
{
let quote = Style::parse(QUOTE_STYLE).expect("valid style");
root = Some(match root {
Some(root) => root.combine("e),
None => quote,
});
}
root
}
fn stack_style(
root: Option<&Style>,
heading: Option<&Style>,
inline: Option<Style>,
link: Option<&str>,
extra: Option<Style>,
) -> Option<Style> {
let mut current: Option<Style> = None;
for layer in [
root.cloned(),
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],
justify: Justify,
) {
let Some(mut text) = current.take() else {
return;
};
if text.plain().is_empty() {
return;
}
text.set_justify(justify);
sink(blocks, stack).push(Block::Text(text));
}
fn push_tilde(
current: &mut Option<Text>,
table: &mut Option<TableAccum>,
link_label: &mut Option<String>,
) {
if let Some(label) = link_label.as_mut() {
label.push('~');
} else {
inline_target(current, table).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));
}
}
enum Piece<'a> {
Event(Event<'a>, std::ops::Range<usize>),
Literal(std::ops::Range<usize>),
Tilde(std::ops::Range<usize>),
Open(std::ops::Range<usize>),
Close(std::ops::Range<usize>),
}
struct Delimiter {
piece: usize,
open: bool,
close: bool,
end: Option<usize>,
emphasis: usize,
}
fn is_md_ascii_punct(c: char) -> bool {
c.is_ascii_punctuation()
}
fn is_punct_char(c: char) -> bool {
c.is_ascii_punctuation() || (!c.is_alphanumeric() && !c.is_whitespace() && !c.is_control())
}
fn scan_delims(last: char, next: char) -> (bool, bool) {
let last_punct = is_md_ascii_punct(last) || is_punct_char(last);
let next_punct = is_md_ascii_punct(next) || is_punct_char(next);
let last_space = last.is_whitespace();
let next_space = next.is_whitespace();
let left_flanking = !(next_space || (next_punct && !(last_space || last_punct)));
let right_flanking = !(last_space || (last_punct && !(next_space || next_punct)));
(left_flanking, right_flanking)
}
fn process_delimiters(delimiters: &mut [Delimiter]) {
if delimiters.is_empty() {
return;
}
let mut openers_bottom = [-1isize; 6];
let mut header = 0usize;
let mut last_piece: isize = -2;
let mut jumps: Vec<usize> = Vec::with_capacity(delimiters.len());
for closer_index in 0..delimiters.len() {
jumps.push(0);
if last_piece != delimiters[closer_index].piece as isize - 1 {
header = closer_index;
}
last_piece = delimiters[closer_index].piece as isize;
if !delimiters[closer_index].close {
continue;
}
let slot = if delimiters[closer_index].open { 3 } else { 0 };
let min_opener = openers_bottom[slot];
let mut opener_index = header as isize - jumps[header] as isize - 1;
let mut new_min = opener_index;
while opener_index > min_opener {
let i = opener_index as usize;
let usable = delimiters[i].open
&& delimiters[i].end.is_none()
&& delimiters[i].emphasis == delimiters[closer_index].emphasis;
if usable {
let last_jump = if i > 0 && !delimiters[i - 1].open {
jumps[i - 1] + 1
} else {
0
};
jumps[closer_index] = closer_index - i + last_jump;
jumps[i] = last_jump;
delimiters[closer_index].open = false;
delimiters[i].end = Some(closer_index);
delimiters[i].close = false;
new_min = -1;
last_piece = -2;
break;
}
opener_index -= jumps[i] as isize + 1;
}
if new_min != -1 {
openers_bottom[slot] = new_min;
}
}
}
enum Rejected {
Autolink,
Bracket {
range: std::ops::Range<usize>,
last_end: usize,
},
}
fn reject_invalid_links<'a>(
source: &'a str,
events: impl Iterator<Item = (Event<'a>, std::ops::Range<usize>)>,
) -> Vec<(Event<'a>, std::ops::Range<usize>)> {
let literal = |range: std::ops::Range<usize>| {
(Event::Text(CowStr::Borrowed(&source[range.clone()])), range)
};
let mut out = Vec::new();
let mut open: Vec<Option<Rejected>> = Vec::new();
for (event, range) in events {
let href = match &event {
Event::Start(Tag::Link {
link_type: LinkType::Email,
dest_url,
..
}) => Some(normalize_link(&format!("mailto:{dest_url}"))),
Event::Start(Tag::Link { dest_url, .. } | Tag::Image { dest_url, .. }) => {
Some(normalize_link(dest_url))
}
_ => None,
};
let pushed = match (&event, href) {
(_, Some(href)) if validate_link(&href) => {
open.push(None);
vec![(event, range.clone())]
}
(
Event::Start(Tag::Link {
link_type: LinkType::Autolink | LinkType::Email,
..
}),
Some(_),
) => {
open.push(Some(Rejected::Autolink));
vec![literal(range.clone())]
}
(Event::Start(tag), Some(_)) => {
let opener = if matches!(tag, Tag::Image { .. }) {
2
} else {
1
};
let opener = range.start..(range.start + opener).min(range.end);
open.push(Some(Rejected::Bracket {
range: range.clone(),
last_end: opener.end,
}));
vec![literal(opener)]
}
(Event::End(TagEnd::Link | TagEnd::Image), _) => match open.pop() {
Some(Some(Rejected::Autolink)) => Vec::new(),
Some(Some(Rejected::Bracket { range, last_end })) => {
vec![literal(last_end.min(range.end)..range.end)]
}
Some(None) | None => vec![(event, range.clone())],
},
_ if matches!(open.last(), Some(Some(Rejected::Autolink))) => Vec::new(),
_ => vec![(event, range.clone())],
};
for (_, pushed_range) in &pushed {
for entry in open.iter_mut() {
if let Some(Rejected::Bracket { last_end, .. }) = entry {
*last_end = (*last_end).max(pushed_range.end);
}
}
}
out.extend(pushed);
}
out
}
fn pair_strikethrough<'a>(
source: &'a str,
events: impl Iterator<Item = (Event<'a>, std::ops::Range<usize>)>,
) -> Vec<(Event<'a>, std::ops::Range<usize>)> {
let mut pieces: Vec<Piece<'a>> = Vec::new();
let mut scopes: Vec<Vec<Delimiter>> = vec![Vec::new()];
let mut finished: Vec<Vec<Delimiter>> = Vec::new();
let mut emphasis_stack: Vec<usize> = Vec::new();
let mut next_emphasis = 1usize;
let mut in_code = false;
let mut in_cell = false;
let mut image_depth = 0usize;
let mut in_autolink = false;
let neighbour = |c: Option<char>, in_cell: bool| match c {
None => ' ',
Some('|') if in_cell => ' ',
Some(c) => c,
};
for (event, range) in events {
if image_depth > 0 {
match &event {
Event::Start(Tag::Image { .. }) => image_depth += 1,
Event::End(TagEnd::Image) => image_depth -= 1,
_ => {}
}
pieces.push(Piece::Event(event, range));
continue;
}
match &event {
Event::Text(text) if !in_code && !in_autolink && **text == source[range.clone()] => {
let mut start = range.start;
if let Some(Piece::Literal(previous)) = pieces.last() {
if previous.end == range.start {
start = previous.start;
pieces.pop();
}
}
let end = range.end;
let bytes = source.as_bytes();
let mut at = start;
let mut literal_from = start;
while at < end {
if bytes[at] != b'~' {
at += 1;
continue;
}
let run_start = at;
while at < end && bytes[at] == b'~' {
at += 1;
}
let length = at - run_start;
if length < 2 {
continue;
}
if literal_from < run_start {
pieces.push(Piece::Literal(literal_from..run_start));
}
let last = neighbour(source[..run_start].chars().next_back(), in_cell);
let next = neighbour(source[at..].chars().next(), in_cell);
let (open, close) = scan_delims(last, next);
let mut from = run_start;
if length % 2 == 1 {
pieces.push(Piece::Literal(from..from + 1));
from += 1;
}
let emphasis = emphasis_stack.last().copied().unwrap_or(0);
while from < at {
pieces.push(Piece::Tilde(from..from + 2));
scopes.last_mut().expect("scope").push(Delimiter {
piece: pieces.len() - 1,
open,
close,
end: None,
emphasis,
});
from += 2;
}
literal_from = at;
}
if literal_from < end {
pieces.push(Piece::Literal(literal_from..end));
}
continue;
}
Event::Start(Tag::Emphasis | Tag::Strong) => {
emphasis_stack.push(next_emphasis);
next_emphasis += 1;
}
Event::End(TagEnd::Emphasis | TagEnd::Strong) => {
emphasis_stack.pop();
}
Event::Start(Tag::Link { link_type, .. }) => {
in_autolink = matches!(link_type, LinkType::Autolink | LinkType::Email);
scopes.push(Vec::new());
}
Event::End(TagEnd::Link) => {
in_autolink = false;
if scopes.len() > 1 {
finished.push(scopes.pop().expect("link scope"));
}
}
Event::Start(Tag::Image { .. }) => image_depth = 1,
Event::Text(_)
| Event::Code(_)
| Event::InlineHtml(_)
| Event::SoftBreak
| Event::HardBreak
| Event::FootnoteReference(_)
| Event::InlineMath(_) => {}
_ => {
match &event {
Event::Start(Tag::CodeBlock(_)) => in_code = true,
Event::End(TagEnd::CodeBlock) => in_code = false,
Event::Start(Tag::TableCell) => in_cell = true,
Event::End(TagEnd::TableCell) => in_cell = false,
_ => {}
}
finished.append(&mut scopes);
scopes.push(Vec::new());
emphasis_stack.clear();
}
}
pieces.push(Piece::Event(event, range));
}
finished.append(&mut scopes);
let mut lone_markers: Vec<usize> = Vec::new();
for mut delimiters in finished {
process_delimiters(&mut delimiters);
for delimiter in &delimiters {
let Some(end) = delimiter.end else { continue };
let closer = delimiters[end].piece;
if let Piece::Tilde(range) = &pieces[delimiter.piece] {
pieces[delimiter.piece] = Piece::Open(range.clone());
}
if let Piece::Tilde(range) = &pieces[closer] {
pieces[closer] = Piece::Close(range.clone());
}
if let Some(Piece::Literal(range)) = closer.checked_sub(1).map(|i| &pieces[i]) {
if &source[range.clone()] == "~" {
lone_markers.push(closer - 1);
}
}
}
}
while let Some(i) = lone_markers.pop() {
let mut j = i + 1;
while j < pieces.len() && matches!(pieces[j], Piece::Close(_)) {
j += 1;
}
j -= 1;
if i != j {
pieces.swap(i, j);
}
}
let mut out: Vec<(Event<'a>, std::ops::Range<usize>)> = Vec::with_capacity(pieces.len());
for piece in pieces {
let (event, range) = match piece {
Piece::Event(event, range) => (event, range),
Piece::Literal(range) | Piece::Tilde(range) => {
(Event::Text(CowStr::Borrowed(&source[range.clone()])), range)
}
Piece::Open(range) => (Event::Start(Tag::Strikethrough), range),
Piece::Close(range) => (Event::End(TagEnd::Strikethrough), range),
};
if let (Event::Text(text), Some((Event::Text(previous), previous_range))) =
(&event, out.last_mut())
{
let mut joined = previous.to_string();
joined.push_str(text);
*previous = CowStr::Boxed(joined.into_boxed_str());
*previous_range =
previous_range.start.min(range.start)..previous_range.end.max(range.end);
continue;
}
out.push((event, range));
}
out
}
fn parse(source: &str, md: &MarkdownOptions) -> Vec<Block> {
let hyperlinks = !md.no_hyperlinks;
let paragraph_justify = md.justify.unwrap_or(Justify::Left);
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 autolink = false;
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;
let events = Parser::new_ext(source, options).into_offset_iter();
let events = reject_invalid_links(source, events);
for (event, range) in pair_strikethrough(source, events.into_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;
}
let preceding_new_line = new_line;
match &event {
Event::End(
TagEnd::Paragraph
| TagEnd::Heading(_)
| TagEnd::List(_)
| TagEnd::Item
| TagEnd::BlockQuote(_)
| TagEnd::CodeBlock
| TagEnd::Table
| TagEnd::TableHead
| TagEnd::TableRow
| TagEnd::TableCell
| TagEnd::HtmlBlock,
) => new_line = true,
Event::Rule => new_line = false,
_ => {}
}
match event {
Event::End(TagEnd::HtmlBlock) => {
sink(&mut blocks, &mut stack).push(Block::Html);
}
Event::Rule => {
flush_pending(&mut current, &mut blocks, &mut stack, paragraph_justify);
sink(&mut blocks, &mut stack).push(Block::Rule);
}
Event::Start(Tag::Link {
link_type,
dest_url,
..
}) => {
link = Some(normalize_link(&match link_type {
LinkType::Email => format!("mailto:{dest_url}"),
_ => dest_url.to_string(),
}));
autolink = matches!(link_type, LinkType::Autolink | LinkType::Email);
if !hyperlinks {
link_label = Some(String::new());
}
}
Event::End(TagEnd::Link) => {
autolink = false;
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);
let block = inline_target(&mut current, &mut table);
let layer = |style: Option<Style>| {
stack_style(
quote_root(md, &stack).as_ref(),
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, .. }) => {
image = Some(normalize_link(&dest_url));
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(
quote_root(md, &stack).as_ref(),
heading_style.as_ref(),
inline_style(strong, emphasis, strike),
link.as_deref().filter(|_| hyperlinks),
None,
),
hyperlinks,
),
joins_next: stack.is_empty() && table.is_none(),
leading_break: new_line,
});
new_line = false;
}
}
Event::Start(Tag::CodeBlock(kind)) => {
flush_pending(&mut current, &mut blocks, &mut stack, paragraph_justify);
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,
theme: md.code_theme.clone(),
});
}
}
Event::Start(Tag::Table(aligns)) => {
flush_pending(&mut current, &mut blocks, &mut stack, paragraph_justify);
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 = Text::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, paragraph_justify);
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 {
blocks: quoted,
leading_break: preceding_new_line,
});
}
}
Event::Start(Tag::List(first)) => {
flush_pending(&mut current, &mut blocks, &mut stack, paragraph_justify);
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 = paragraph_justify;
}
Event::End(TagEnd::Item) => {
if let Some(mut text) = current.take() {
text.set_justify(paragraph_justify);
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, paragraph_justify);
current = Some(Text::new(""));
heading_style = None;
justify = paragraph_justify;
}
Event::Start(Tag::Heading { level, .. }) => {
flush_pending(&mut current, &mut blocks, &mut stack, paragraph_justify);
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 {
if let Some(root) = quote_root(md, &stack) {
text.set_base_style(root);
}
}
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 table, &mut link_label);
}
}
Event::End(TagEnd::Strikethrough) => {
if single_tilde > 0 {
single_tilde -= 1;
push_tilde(&mut current, &mut table, &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) => {
let text = if autolink {
CowStr::from(normalize_link_text(&text))
} else {
text
};
if let Some(label) = link_label.as_mut() {
label.push_str(&text);
} else if let Some((_, source)) = code.as_mut() {
source.push_str(&text);
} else {
let block = inline_target(&mut current, &mut table);
let style = stack_style(
quote_root(md, &stack).as_ref(),
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 {
let block = inline_target(&mut current, &mut table);
if let Some(lexer) = &md.inline_code_lexer {
let theme = md.inline_code_theme.as_ref().or(md.code_theme.as_ref());
let mut syntax = Syntax::new(text.to_string(), lexer.as_str());
if let Some(theme) = theme {
syntax = syntax.theme(theme.as_str());
}
let mut highlighted = syntax.highlight();
highlighted.rstrip();
let style = stack_style(
quote_root(md, &stack).as_ref(),
heading_style.as_ref(),
inline_style(strong, emphasis, strike),
link.as_deref().filter(|_| hyperlinks),
None,
);
let mut fragment = Text::new("");
if let Some(style) = style {
fragment.set_base_style(style);
}
let fragment = fragment.append_text(&highlighted);
*block = std::mem::take(block).append_text(&fragment);
continue;
}
let style = stack_style(
quote_root(md, &stack).as_ref(),
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(
quote_root(md, &stack).as_ref(),
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(
quote_root(md, &stack).as_ref(),
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,
self.options.style.as_ref(),
);
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,
root: Option<&Style>,
) -> 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 mut merge = std::mem::take(&mut join_previous);
if matches!(
block,
Block::Image {
leading_break: false,
..
}
) && index > 0
&& matches!(blocks[index - 1], Block::Image { .. })
{
merge = true;
}
if matches!(
block,
Block::Image {
leading_break: true,
..
}
) {
merge = false;
}
let after_rule = index > 0 && matches!(blocks[index - 1], Block::Rule);
let own_gap = matches!(block, Block::List { .. } | Block::Table { .. });
let after_image = index > 0 && matches!(blocks[index - 1], Block::Image { .. });
let separator = match block {
Block::Quote { leading_break, .. } => top_level && *leading_break && !after_image,
Block::Image { leading_break, .. } => top_level && *leading_break && !after_image,
_ 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,
root,
);
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),
Some(prefix_style.clone()),
));
}
match root {
Some(root) => row.extend(Segment::apply_style(&line, root)),
None => row.extend(line),
}
lines.push(row);
}
}
}
Block::Html => {}
Block::Quote { blocks: quoted, .. } => {
let quote = Style::parse(QUOTE_STYLE).expect("valid style");
let prefix_style = match root {
Some(root) => root.combine("e),
None => quote,
};
let content_width = width.saturating_sub(4);
let quoted_lines = render_blocks(
quoted,
console,
options,
content_width,
false,
Some(&prefix_style),
);
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,
theme,
} => {
let mut syntax = Syntax::new(code.as_str(), language.as_str())
.word_wrap(true)
.padding(1);
if let Some(theme) = theme {
syntax = syntax.theme(theme.as_str());
}
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_text(header.clone(), justify);
table.column_header_style(header_style.clone());
}
for row in rows {
table.add_row_text(row.clone());
}
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))
}
fn render_with(markdown: &Markdown) -> String {
let console = Console::builder()
.force_terminal(true)
.color_system(Some(ColorSystem::Truecolor))
.width(30)
.build();
console.render_to_string(markdown)
}
#[test]
fn code_theme_changes_the_code_block_colours() {
let source = "```rust\nfn main() {}\n```";
let default = render_with(&Markdown::new(source));
let themed = render_with(&Markdown::new(source).code_theme("InspiredGitHub"));
assert_ne!(default, themed);
assert_eq!(
default,
render_with(&Markdown::new(source).code_theme("no-such-theme"))
);
}
#[test]
fn inline_code_lexer_highlights_instead_of_the_code_style() {
let source = "Call `fn main() {}` now.";
let plain = render_with(&Markdown::new(source));
assert!(plain.contains("\x1b[1;36;40m"), "{plain:?}");
let highlighted = render_with(&Markdown::new(source).inline_code_lexer("rust"));
assert!(!highlighted.contains("\x1b[1;36;40m"), "{highlighted:?}");
assert_ne!(plain, highlighted);
let text = Console::builder().width(30).color_system(None).build();
assert_eq!(
text.render_to_string(&Markdown::new(source).inline_code_lexer("rust")),
text.render_to_string(&Markdown::new(source)),
"highlighting changes colours only, never the text"
);
let by_code_theme = render_with(
&Markdown::new(source)
.inline_code_lexer("rust")
.code_theme("InspiredGitHub"),
);
let by_inline_theme = render_with(
&Markdown::new(source)
.inline_code_lexer("rust")
.inline_code_theme("InspiredGitHub"),
);
assert_ne!(highlighted, by_code_theme);
assert_eq!(by_code_theme, by_inline_theme);
}
#[test]
fn a_code_only_list_item_keeps_the_bullet_on_its_padding_row() {
let console = Console::builder().width(30).color_system(None).build();
assert_eq!(console.render_export(&Markdown::new("- ```\n code\n ```")),
"\n โข \n code \n \n");
}
#[test]
fn table_cell_images_share_a_row_until_the_cell_closes() {
let console = Console::builder().width(30).color_system(None).build();
let output = console.render_to_string(&Markdown::new(
"| h |\n|---|\n|   |\n|  |",
));
assert!(output.starts_with("\n๐ a ๐ b \n๐ c \n"), "{output:?}");
}
#[test]
fn quoted_rule_spacing_uses_the_last_closed_child() {
let console = Console::builder().width(30).color_system(None).build();
assert_eq!(
console.render_to_string(&Markdown::new("> ---")),
"โ --------------------------\nโ "
);
let output = console.render_to_string(&Markdown::new("> ---\n>\n> text"));
assert!(
output.starts_with("\nโ --------------------------\n"),
"{output:?}"
);
}
#[test]
fn ignored_html_blocks_keep_upstream_paragraph_spacing() {
let console = Console::builder().width(30).color_system(None).build();
for (source, expected) in [
(
"<div>hidden</div>\n\nParagraph",
"\nParagraph ",
),
("<div>hidden</div>", ""),
(
"A\n\n<div>x</div>\n\nB",
"A \n\n\nB ",
),
] {
assert_eq!(console.render_to_string(&Markdown::new(source)), expected);
}
}
#[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).color_system(None).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)
.color_system(None)
.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:?}");
}
#[test]
fn table_cell_tildes_pair_like_markdown_it() {
let out = plain("| h |\n|---|\n| ~~~c~~~ |\n", 20, false);
assert!(out.contains("~c~"), "{out:?}");
assert!(!out.contains("~~"), "{out:?}");
}
#[test]
fn code_block_tildes_are_untouched() {
let out = plain("```\na ~~~x~~~ b\n```\n", 30, false);
assert!(out.contains("a ~~~x~~~ b"), "{out:?}");
}
#[test]
fn tildes_crossing_a_later_emphasis_stay_literal() {
let out = plain("~~a *b~~ c*", 30, false);
assert!(out.contains("~~a b~~ c"), "{out:?}");
}
}