use crate::elements::*;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
const RESET: &str = "\x1b[0m";
const BOLD: &str = "\x1b[1m";
const ITALIC: &str = "\x1b[3m";
const STRIKE: &str = "\x1b[9m";
const INVERSE: &str = "\x1b[7m";
const UNDERLINE: &str = "\x1b[4m";
const DIM: &str = "\x1b[2m";
const MIN_COL_WIDTH: usize = 3;
fn is_html_tag(name: &str) -> bool {
let lower = name.to_lowercase();
matches!(
lower.as_str(),
"b" | "i"
| "u"
| "s"
| "em"
| "strong"
| "del"
| "ins"
| "br"
| "br/"
| "hr"
| "div"
| "span"
| "p"
| "a"
| "h1"
| "h2"
| "h3"
| "h4"
| "h5"
| "h6"
| "ul"
| "ol"
| "li"
| "table"
| "tr"
| "td"
| "th"
| "pre"
| "code"
| "blockquote"
| "img"
| "sup"
| "sub"
)
}
fn resolve_emoji(text: &str) -> Option<(&str, String)> {
if !text.starts_with(':') {
return None;
}
if let Some(end) = text[1..].find(':') {
let shortcode = &text[0..end + 2];
let name = &text[1..end + 1];
let emoji = match name {
"smile" | "smiley" => "😊",
"heart" => "❤",
"rocket" => "🚀",
"warning" => "⚠",
"check" | "white_check_mark" => "✅",
"x" => "❌",
"star" => "⭐",
"fire" => "🔥",
"info" | "information_source" => "ℹ",
"bug" => "🐛",
"tada" => "🎉",
"lock" => "🔒",
"unlock" => "🔓",
"key" => "🔑",
"bulb" | "lightbulb" => "💡",
"wrench" => "🔧",
"hammer" => "🔨",
"gear" => "⚙",
"book" => "📖",
"pencil" => "✏",
"memo" => "📝",
"mail" | "email" => "📧",
"link" => "🔗",
"clock" => "🕐",
"hourglass" => "⏳",
"question" => "❓",
"exclamation" | "bang" => "❗",
"arrow_right" => "→",
"arrow_left" => "←",
"arrow_up" => "↑",
"arrow_down" => "↓",
"muscle" => "💪",
"package" => "📦",
"eyes" => "👀",
"sparkles" => "✨",
"zap" => "⚡",
"+1" | "thumbsup" => "👍",
"-1" | "thumbsdown" => "👎",
"ok" | "ok_hand" => "👌",
"construction" => "🚧",
"beetle" => "🪲",
"truck" => "🚚",
"recycle" => "♻",
_ => return None,
};
Some((shortcode, emoji.to_string()))
} else {
None
}
}
fn visible_width(s: &str) -> usize {
let mut width = 0;
let mut in_escape = false;
for ch in s.chars() {
if in_escape {
if ch == 'm' {
in_escape = false;
}
} else if ch == '\x1b' {
in_escape = true;
} else {
width += ch.width().unwrap_or(0);
}
}
width
}
fn wrap_ansi_words(text: &str, max_width: usize) -> Vec<String> {
if text.is_empty() {
return vec![String::new()];
}
let words = split_ansi_words(text);
let mut lines = Vec::new();
let mut current = String::new();
let mut current_width = 0usize;
for word in &words {
let ww = visible_width(word);
if current.is_empty() {
current = word.clone();
current_width = ww;
} else if current_width + 1 + ww <= max_width {
current.push(' ');
current.push_str(word);
current_width += 1 + ww;
} else {
lines.push(current);
current = word.clone();
current_width = ww;
}
}
if !current.is_empty() {
lines.push(current);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
fn split_ansi_words(text: &str) -> Vec<String> {
let mut words = Vec::new();
let mut cur = String::new();
let mut in_esc = false;
for ch in text.chars() {
if in_esc {
cur.push(ch);
if ch == 'm' {
in_esc = false;
}
} else if ch == '\x1b' {
in_esc = true;
cur.push(ch);
} else if ch.is_whitespace() {
if !cur.is_empty() {
words.push(std::mem::take(&mut cur));
}
} else {
cur.push(ch);
}
}
if !cur.is_empty() {
words.push(cur);
}
words
}
fn render_inline(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
let mut out = String::with_capacity(text.len() * 2);
let mut i = 0;
while i < len {
if chars[i] == '\\' && i + 1 < len && chars[i + 1].is_ascii_punctuation() {
out.push(chars[i + 1]);
i += 2;
continue;
}
if i + 1 < len
&& chars[i] == '*'
&& chars[i + 1] == '*'
&& let Some(end) = find_closer(&chars, i + 2, "**")
{
let inner = render_inline(&chars[i + 2..end].iter().collect::<String>());
out.push_str(BOLD);
out.push_str(&inner);
out.push_str(RESET);
i = end + 2;
continue;
}
if i + 1 < len
&& chars[i] == '_'
&& chars[i + 1] == '_'
&& let Some(end) = find_closer(&chars, i + 2, "__")
{
let inner = render_inline(&chars[i + 2..end].iter().collect::<String>());
out.push_str(BOLD);
out.push_str(&inner);
out.push_str(RESET);
i = end + 2;
continue;
}
if chars[i] == '*'
&& (i + 1 >= len || chars[i + 1] != '*')
&& (i == 0 || chars[i - 1] != '*')
&& let Some(end) = find_single_closer(&chars, i + 1, '*')
{
let inner = render_inline(&chars[i + 1..end].iter().collect::<String>());
out.push_str(ITALIC);
out.push_str(&inner);
out.push_str(RESET);
i = end + 1;
continue;
}
if chars[i] == '_'
&& (i + 1 >= len || chars[i + 1] != '_')
&& (i == 0 || chars[i - 1] != '_')
&& let Some(end) = find_single_closer(&chars, i + 1, '_')
{
let inner = render_inline(&chars[i + 1..end].iter().collect::<String>());
out.push_str(ITALIC);
out.push_str(&inner);
out.push_str(RESET);
i = end + 1;
continue;
}
if i + 1 < len
&& chars[i] == '='
&& chars[i + 1] == '='
&& let Some(end) = find_closer(&chars, i + 2, "==")
{
let inner = render_inline(&chars[i + 2..end].iter().collect::<String>());
out.push_str(INVERSE);
out.push_str(&inner);
out.push_str(RESET);
i = end + 2;
continue;
}
if chars[i] == '^'
&& (i == 0 || chars[i - 1] != '^')
&& let Some(end) = find_single_closer(&chars, i + 1, '^')
{
let inner: String = chars[i + 1..end].iter().collect();
out.push_str("{^");
out.push_str(&inner);
out.push('}');
i = end + 1;
continue;
}
if i + 1 < len
&& chars[i] == '~'
&& chars[i + 1] == '~'
&& let Some(end) = find_closer(&chars, i + 2, "~~")
{
let inner = render_inline(&chars[i + 2..end].iter().collect::<String>());
out.push_str(STRIKE);
out.push_str(&inner);
out.push_str(RESET);
i = end + 2;
continue;
}
if chars[i] == '~'
&& (i + 1 >= len || chars[i + 1] != '~')
&& (i == 0 || chars[i - 1] != '~')
&& let Some(end) = find_single_closer(&chars, i + 1, '~')
{
let inner: String = chars[i + 1..end].iter().collect();
out.push_str("{_");
out.push_str(&inner);
out.push('}');
i = end + 1;
continue;
}
if chars[i] == '$'
&& let Some(end) = find_single_closer(&chars, i + 1, '$')
{
let inner: String = chars[i + 1..end].iter().collect();
out.push_str(DIM);
out.push_str(&inner);
out.push_str(RESET);
i = end + 1;
continue;
}
if chars[i] == ':' {
let rest: String = chars[i..].iter().collect();
if let Some((shortcode, emoji)) = resolve_emoji(&rest) {
out.push_str(&emoji);
i += shortcode.len();
continue;
}
}
if chars[i] == '`'
&& let Some(end) = find_single_closer(&chars, i + 1, '`')
{
let inner: String = chars[i + 1..end].iter().collect();
out.push_str(INVERSE);
out.push(' ');
out.push_str(inner.trim());
out.push(' ');
out.push_str(RESET);
i = end + 1;
continue;
}
if chars[i] == '!'
&& i + 1 < len
&& chars[i + 1] == '['
&& let Some(end_b) = find_single_closer(&chars, i + 2, ']')
&& end_b + 1 < len
&& chars[end_b + 1] == '('
&& let Some(end_p) = find_single_closer(&chars, end_b + 2, ')')
{
let inner: String = chars[i + 2..end_b].iter().collect();
let alt = if inner.is_empty() {
"img".to_string()
} else {
inner
};
out.push_str(DIM);
out.push_str("[img: ");
out.push_str(&alt);
out.push(']');
out.push_str(RESET);
i = end_p + 1;
continue;
}
if chars[i] == '['
&& let Some(end_b) = find_single_closer(&chars, i + 1, ']')
&& end_b + 1 < len
&& chars[end_b + 1] == '('
&& let Some(end_p) = find_single_closer(&chars, end_b + 2, ')')
{
let inner = render_inline(&chars[i + 1..end_b].iter().collect::<String>());
out.push_str(UNDERLINE);
out.push_str(&inner);
out.push_str(RESET);
i = end_p + 1;
continue;
}
if chars[i] == '<' {
let rest: String = chars[i + 1..].iter().collect();
if let Some(end) = rest.find('>') {
let content = &rest[..end];
if content.contains("://") || (content.contains('@') && !content.contains(' ')) {
out.push_str(UNDERLINE);
out.push_str(content);
out.push_str(RESET);
i += end + 2;
continue;
}
let tag = content.trim_start_matches('/');
if is_html_tag(tag) {
i += end + 2;
continue;
}
}
}
out.push(chars[i]);
i += 1;
}
out
}
fn find_closer(chars: &[char], start: usize, pat: &str) -> Option<usize> {
let p: Vec<char> = pat.chars().collect();
let pl = p.len();
let mut i = start;
while i + pl <= chars.len() {
if chars[i..i + pl] == p[..] {
return Some(i);
}
i += 1;
}
None
}
fn find_single_closer(chars: &[char], start: usize, t: char) -> Option<usize> {
chars[start..]
.iter()
.position(|&c| c == t)
.map(|p| start + p)
}
use crate::highlight::ThemeMode;
pub fn render_element(
elem: &MarkdownElement,
width: usize,
theme_mode: ThemeMode,
code_theme: Option<&str>,
) -> Vec<String> {
render_element_with_options(elem, width, theme_mode, code_theme, false)
}
pub fn render_element_with_options(
elem: &MarkdownElement,
width: usize,
theme_mode: ThemeMode,
code_theme: Option<&str>,
ascii_table_borders: bool,
) -> Vec<String> {
match elem {
MarkdownElement::BlankLine => vec![String::new()],
MarkdownElement::HorizontalRule => vec!["─".repeat(width)],
MarkdownElement::Heading { level, text } => render_heading(*level, text, width),
MarkdownElement::Paragraph { text } => render_paragraph(text, width),
MarkdownElement::CodeBlock { language, lines } => {
render_code_block(lines, language, width, theme_mode, code_theme)
}
MarkdownElement::Blockquote { text } => render_blockquote(text, width),
MarkdownElement::OrderedList {
items,
start,
depth,
} => render_ordered_list(items, *start, width, *depth),
MarkdownElement::UnorderedList { items, depth } => {
render_unordered_list(items, width, *depth)
}
MarkdownElement::Table(td) => render_table(td, width, ascii_table_borders),
MarkdownElement::HtmlBlock { lines } => render_html_block(lines, width),
MarkdownElement::TaskList { items, depth } => render_task_list(items, width, *depth),
MarkdownElement::FootnoteSection { items } => render_footnote_section(items, width),
MarkdownElement::DefinitionList { items } => render_definition_list(items, width),
}
}
fn heading_symbol(level: u8) -> char {
match level {
1 => '◆',
2 => '●',
3 => '▼',
4 => '▾',
5 => '▿',
_ => '·',
}
}
fn render_heading(level: u8, text: &str, width: usize) -> Vec<String> {
let styled = render_inline(text);
let symbol = heading_symbol(level);
let prefix = format!("{BOLD}{symbol}{RESET} ");
let pw = prefix.width();
let wrapped = wrap_ansi_words(&styled, width.saturating_sub(pw));
let mut out = Vec::new();
for (wi, line) in wrapped.iter().enumerate() {
if wi == 0 {
out.push(format!("{prefix}{line}"));
} else {
out.push(format!("{}{}", " ".repeat(pw), line));
}
}
out
}
fn render_paragraph(text: &str, width: usize) -> Vec<String> {
wrap_ansi_words(&render_inline(text), width)
}
fn render_html_block(lines: &[String], _width: usize) -> Vec<String> {
lines
.iter()
.map(|l| {
if l.trim().is_empty() {
String::new()
} else {
format!("{DIM}{}{RESET}", l)
}
})
.collect()
}
fn render_task_list(items: &[TaskItem], width: usize, depth: u8) -> Vec<String> {
let indent = 4 * depth as usize;
let cw = width.saturating_sub(4 + indent);
let prefix_indent = " ".repeat(indent);
let mut out = Vec::new();
for item in items {
let mark = if item.checked { "☑" } else { "☐" };
for (wi, line) in wrap_ansi_words(&render_inline(&item.text), cw)
.iter()
.enumerate()
{
if wi == 0 {
out.push(format!("{prefix_indent}{mark} {line}"));
} else {
out.push(format!("{prefix_indent} {line}"));
}
}
}
out
}
fn render_footnote_section(items: &[FootnoteDef], width: usize) -> Vec<String> {
let mut out = vec!["───".to_string(), String::new()];
for item in items {
let prefix = format!("[^{}]: ", item.id);
let pw = prefix.width();
let cw = width.saturating_sub(pw);
for (wi, line) in wrap_ansi_words(&render_inline(&item.text), cw)
.iter()
.enumerate()
{
if wi == 0 {
out.push(format!("{DIM}{prefix}{RESET}{line}"));
} else {
out.push(format!("{}{line}", " ".repeat(pw)));
}
}
}
out
}
fn render_definition_list(items: &[(String, Vec<String>)], width: usize) -> Vec<String> {
let mut out = Vec::new();
for (term, defs) in items {
out.push(format!("{BOLD}{}{RESET}", render_inline(term)));
for def in defs {
let cw = width.saturating_sub(4);
for (wi, line) in wrap_ansi_words(&render_inline(def), cw).iter().enumerate() {
if wi == 0 {
out.push(format!(" : {line}"));
} else {
out.push(format!(" {line}"));
}
}
}
}
out
}
fn render_code_block(
lines: &[String],
language: &Option<String>,
_width: usize,
theme_mode: ThemeMode,
code_theme: Option<&str>,
) -> Vec<String> {
if lines.is_empty() {
return vec![];
}
#[cfg(feature = "syntax-highlight")]
if let Some(lang) = language
&& let Some(hl) = crate::highlight::highlight_lines(lang, lines, theme_mode, code_theme)
{
return hl;
}
let _ = language;
lines.iter().map(|l| format!(" {l}")).collect()
}
fn render_blockquote(text: &str, width: usize) -> Vec<String> {
wrap_ansi_words(&render_inline(text), width.saturating_sub(2))
.into_iter()
.map(|l| format!("│ {l}"))
.collect()
}
fn render_ordered_list(items: &[String], start: u64, width: usize, depth: u8) -> Vec<String> {
let indent = 4 * depth as usize;
let last = start + items.len() as u64 - 1;
let pw = last.to_string().len() + 2;
let cw = width.saturating_sub(pw + indent);
let prefix_indent = " ".repeat(indent);
let mut out = Vec::new();
for (idx, item) in items.iter().enumerate() {
let num = start + idx as u64;
let prefix = format!("{prefix_indent}{num}. ");
for (wi, line) in wrap_ansi_words(&render_inline(item), cw).iter().enumerate() {
if wi == 0 {
out.push(format!("{prefix}{line}"));
} else {
out.push(format!("{}{prefix_indent}{}", " ".repeat(pw), line));
}
}
}
out
}
fn render_unordered_list(items: &[String], width: usize, depth: u8) -> Vec<String> {
let indent = 4 * depth as usize;
let cw = width.saturating_sub(2 + indent);
let prefix_indent = " ".repeat(indent);
let mut out = Vec::new();
for item in items {
for (wi, line) in wrap_ansi_words(&render_inline(item), cw).iter().enumerate() {
if wi == 0 {
out.push(format!("{prefix_indent}• {line}"));
} else {
out.push(format!("{prefix_indent} {line}"));
}
}
}
out
}
fn render_table(td: &TableDef, width: usize, ascii: bool) -> Vec<String> {
let nc = td.headers.len();
if nc == 0 {
return vec![];
}
let has_fill = td.has_fill_column();
let cs = 3 * nc + 1;
let mut col_min = vec![0usize; nc];
let mut col_w = vec![0usize; nc];
for (ci, h) in td.headers.iter().enumerate() {
let ht = h.trim();
col_min[ci] = if has_fill && ht.is_empty() {
1
} else {
ht.width().max(MIN_COL_WIDTH)
};
}
for row in &td.rows {
for (ci, cell) in row.iter().enumerate() {
if ci < nc && !(has_fill && td.headers[ci].trim().is_empty()) {
let mw = cell
.split_whitespace()
.map(|w| w.width())
.max()
.unwrap_or(1);
col_min[ci] = col_min[ci].max(mw).max(MIN_COL_WIDTH);
}
}
}
for ci in 0..nc {
col_w[ci] = col_min[ci].max(1);
}
let cidx: Vec<usize> = (0..nc)
.filter(|&ci| !(has_fill && td.headers[ci].trim().is_empty()))
.collect();
let avail = width.saturating_sub(col_min.iter().sum::<usize>() + cs);
if !cidx.is_empty() && avail > 0 {
let per = avail / cidx.len();
let rem = avail % cidx.len();
for (idx, &ci) in cidx.iter().enumerate() {
col_w[ci] += if idx < rem { per + 1 } else { per };
}
}
if has_fill {
let used: usize = col_w.iter().sum();
let rem = width.saturating_sub(used + cs);
let fidx: Vec<usize> = (0..nc)
.filter(|&ci| td.headers[ci].trim().is_empty())
.collect();
if !fidx.is_empty() {
for &fi in &fidx {
col_w[fi] = (rem / fidx.len()).max(1);
}
}
}
let hl: Vec<Vec<String>> = td
.headers
.iter()
.enumerate()
.map(|(ci, h)| wrap_simple(h.trim(), col_w[ci]))
.collect();
let rl: Vec<Vec<Vec<String>>> = td
.rows
.iter()
.map(|row| {
row.iter()
.enumerate()
.map(|(ci, c)| wrap_simple(c, col_w[ci]))
.collect()
})
.collect();
let (top_l, top_m, top_r, mid_l, mid_m, mid_r, bot_l, bot_m, bot_r, horiz, vert) = if ascii {
('+', '+', '+', '+', '+', '+', '+', '+', '+', '-', '|')
} else {
('┌', '┬', '┐', '├', '┼', '┤', '└', '┴', '┘', '─', '│')
};
let mut out = Vec::new();
out.push(border(&col_w, top_l, top_m, top_r, horiz));
for li in 0..hl.iter().map(|l| l.len()).max().unwrap_or(1) {
let mut s = String::from(vert);
for ci in 0..nc {
let t = hl[ci].get(li).map(|l| l.as_str()).unwrap_or("");
s.push_str(&pad_cell(
t,
col_w[ci],
td.alignments.get(ci).copied().unwrap_or(Alignment::Left),
));
s.push(vert);
}
out.push(s);
}
if !td.rows.is_empty() {
out.push(border(&col_w, mid_l, mid_m, mid_r, horiz));
}
let num_data_rows = rl.len();
for (ri, row) in rl.iter().enumerate() {
let mut rendered_any_lines = false;
for li in 0..row.iter().map(|r| r.len()).max().unwrap_or(1) {
let mut s = String::from(vert);
for (ci, cl) in row.iter().enumerate() {
let t = cl.get(li).map(|l| l.as_str()).unwrap_or("");
s.push_str(&pad_cell(
t,
col_w[ci],
td.alignments.get(ci).copied().unwrap_or(Alignment::Left),
));
s.push(vert);
}
let has_visible_content = s.chars().any(|c| c != ' ' && c != '│');
if has_visible_content {
out.push(s);
rendered_any_lines = true;
}
}
if rendered_any_lines && num_data_rows > 1 && ri < num_data_rows - 1 {
out.push(border(&col_w, mid_l, mid_m, mid_r, horiz));
}
}
out.push(border(&col_w, bot_l, bot_m, bot_r, horiz));
out
}
fn border(widths: &[usize], left: char, mid: char, right: char, horiz: char) -> String {
let mut s = String::new();
s.push(left);
for (i, &w) in widths.iter().enumerate() {
for _ in 0..(w + 2) {
s.push(horiz);
}
if i + 1 < widths.len() {
s.push(mid);
}
}
s.push(right);
s
}
fn pad_cell(text: &str, width: usize, align: Alignment) -> String {
let tw = text.width();
let pad = width.saturating_sub(tw);
let (lp, rp) = match align {
Alignment::Left => (0, pad),
Alignment::Center => (pad / 2, pad - pad / 2),
Alignment::Right => (pad, 0),
};
format!(" {}{}{} ", " ".repeat(lp), text, " ".repeat(rp))
}
fn wrap_simple(text: &str, width: usize) -> Vec<String> {
if text.is_empty() {
return vec![String::new()];
}
let mut lines = Vec::new();
let text = html_breaks_to_newlines(text);
for para in text.split('\n') {
let words: Vec<&str> = para.split_whitespace().collect();
if words.is_empty() {
lines.push(String::new());
continue;
}
let mut cur = String::new();
let mut cw = 0usize;
for w in words {
let ww = w.width();
if cw == 0 {
cur = w.to_string();
cw = ww;
} else if cw + 1 + ww <= width {
cur.push(' ');
cur.push_str(w);
cw += 1 + ww;
} else {
lines.push(cur);
cur = w.to_string();
cw = ww;
}
}
if !cur.is_empty() {
lines.push(cur);
}
}
lines
}
fn html_breaks_to_newlines(text: &str) -> String {
let mut output = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find('<') {
output.push_str(&rest[..start]);
let candidate = &rest[start..];
let Some(end) = candidate.find('>') else {
output.push_str(candidate);
return output;
};
let tag = &candidate[1..end];
let tag = tag.trim().trim_end_matches('/').trim();
if tag.eq_ignore_ascii_case("br") {
output.push('\n');
rest = &candidate[end + 1..];
} else {
output.push_str(&candidate[..=end]);
rest = &candidate[end + 1..];
}
}
output.push_str(rest);
output
}
#[cfg(test)]
mod tests {
use super::*;
use crate::highlight::ThemeMode;
#[test]
fn visible_width_plain() {
assert_eq!(visible_width("hello"), 5);
}
#[test]
fn visible_width_ansi_stripped() {
assert_eq!(visible_width("\x1b[1mhello\x1b[0m"), 5);
}
#[test]
fn visible_width_cjk() {
assert_eq!(visible_width("中文"), 4);
}
#[test]
fn wrap_no_wrap_needed() {
assert_eq!(wrap_ansi_words("hello world", 20), vec!["hello world"]);
}
#[test]
fn wrap_at_boundary() {
assert_eq!(wrap_ansi_words("hello world", 6), vec!["hello", "world"]);
}
#[test]
fn wrap_empty_string() {
assert_eq!(wrap_ansi_words("", 80), vec![""]);
}
#[test]
fn wrap_ansi_preserved() {
assert!(wrap_ansi_words("\x1b[1mhello\x1b[0m world", 6)[0].contains("\x1b[1m"));
}
#[test]
fn inline_bold() {
assert!(render_inline("**bold**").contains("\x1b[1mbold\x1b[0m"));
}
#[test]
fn inline_italic_star() {
assert!(render_inline("*italic*").contains("\x1b[3mitalic\x1b[0m"));
}
#[test]
fn inline_strikethrough() {
assert!(render_inline("~~gone~~").contains("\x1b[9mgone\x1b[0m"));
}
#[test]
fn inline_code() {
assert!(render_inline("`code`").contains("\x1b[7m code \x1b[0m"));
}
#[test]
fn inline_link() {
assert!(render_inline("[link](url)").contains("\x1b[4mlink\x1b[0m"));
}
#[test]
fn inline_nested_bold_italic() {
let r = render_inline("***both***");
assert!(
r.contains("\x1b[1m") || r.contains("\x1b[3m"),
"***both*** got: {r}"
);
}
#[test]
fn unescape_literal_backslash() {
assert_eq!(render_inline(r"\*"), "*");
}
#[test]
fn unescape_preserves_regular_text() {
assert_eq!(render_inline("hello"), "hello");
}
#[test]
fn unescape_multiple_escapes() {
assert_eq!(render_inline(r"\*\`\_\\"), "*`_\\");
}
#[test]
fn unescape_does_not_escape_non_punct() {
assert_eq!(render_inline(r"\a"), r"\a");
}
#[test]
fn inline_escaped_asterisk_not_bold() {
assert!(!render_inline(r"\*not bold\*").contains("\x1b[1m"));
}
#[test]
fn inline_escaped_underscore_not_italic() {
assert!(!render_inline(r"\_plain\_").contains("\x1b[3m"));
}
#[test]
fn inline_escaped_backtick_not_code() {
assert!(!render_inline(r"\`text\`").contains("\x1b[7m"));
}
#[test]
fn inline_escaped_bracket_not_link() {
assert!(!render_inline(r"\[text\](url)").contains("\x1b[4m"));
}
#[test]
fn inline_autolink_url() {
assert!(
render_inline("<https://example.com>").contains("\x1b[4mhttps://example.com\x1b[0m")
);
}
#[test]
fn inline_autolink_email() {
assert!(render_inline("<user@example.com>").contains("\x1b[4muser@example.com\x1b[0m"));
}
#[test]
fn inline_no_autolink_plain_angle() {
assert_eq!(render_inline("<not a link>"), "<not a link>");
}
#[test]
fn render_horizontal_rule() {
assert!(
render_element(&MarkdownElement::HorizontalRule, 40, ThemeMode::Dark, None)[0]
.starts_with("─")
);
}
#[test]
fn render_blank_line() {
assert_eq!(
render_element(&MarkdownElement::BlankLine, 80, ThemeMode::Dark, None),
vec![""]
);
}
#[test]
fn render_heading_h1() {
let r = render_element(
&MarkdownElement::Heading {
level: 1,
text: "Title".into(),
},
40,
ThemeMode::Dark,
None,
);
assert_eq!(r.len(), 1);
assert!(r[0].contains("◆"));
}
#[test]
fn render_heading_h2() {
assert!(
render_element(
&MarkdownElement::Heading {
level: 2,
text: "S".into()
},
40,
ThemeMode::Dark,
None
)[0]
.contains("●")
);
}
#[test]
fn render_heading_h3() {
assert!(
render_element(
&MarkdownElement::Heading {
level: 3,
text: "S".into()
},
40,
ThemeMode::Dark,
None
)[0]
.contains("▼")
);
}
#[test]
fn render_heading_h4() {
assert!(
render_element(
&MarkdownElement::Heading {
level: 4,
text: "S".into()
},
40,
ThemeMode::Dark,
None
)[0]
.contains("▾")
);
}
#[test]
fn render_heading_h5() {
assert!(
render_element(
&MarkdownElement::Heading {
level: 5,
text: "S".into()
},
40,
ThemeMode::Dark,
None
)[0]
.contains("▿")
);
}
#[test]
fn render_heading_h6() {
assert!(
render_element(
&MarkdownElement::Heading {
level: 6,
text: "S".into()
},
40,
ThemeMode::Dark,
None
)[0]
.contains("·")
);
}
#[test]
fn render_blockquote() {
assert!(
render_element(
&MarkdownElement::Blockquote { text: "q".into() },
40,
ThemeMode::Dark,
None
)[0]
.starts_with("│ ")
);
}
#[test]
fn render_unordered_list_single() {
assert!(
render_element(
&MarkdownElement::UnorderedList {
items: vec!["i".into()],
depth: 0
},
40,
ThemeMode::Dark,
None
)[0]
.starts_with("• ")
);
}
#[test]
fn render_ordered_list() {
let r = render_element(
&MarkdownElement::OrderedList {
items: vec!["a".into(), "b".into()],
start: 1,
depth: 0,
},
40,
ThemeMode::Dark,
None,
);
assert!(r[0].starts_with("1. "));
assert!(r[1].starts_with("2. "));
}
#[test]
fn render_code_block_no_lang() {
assert_eq!(
render_element(
&MarkdownElement::CodeBlock {
language: None,
lines: vec!["x".into()]
},
80,
ThemeMode::Dark,
None
),
vec![" x"]
);
}
#[test]
fn render_code_block_empty() {
assert!(
render_element(
&MarkdownElement::CodeBlock {
language: None,
lines: vec![]
},
80,
ThemeMode::Dark,
None
)
.is_empty()
);
}
#[test]
fn render_table_simple() {
let td = TableDef {
headers: vec!["a".into()],
alignments: vec![Alignment::Left],
rows: vec![vec!["1".into()]],
};
let r = render_element(&MarkdownElement::Table(td), 80, ThemeMode::Dark, None);
assert!(r[0].contains("┌"));
assert!(r.last().unwrap().contains("└"));
}
#[test]
fn render_table_simple_ascii() {
let td = TableDef {
headers: vec!["a".into()],
alignments: vec![Alignment::Left],
rows: vec![vec!["1".into()]],
};
let r = render_table(&td, 80, true);
assert!(r[0].contains("+"));
assert!(!r[0].contains("┌"));
}
#[test]
fn render_table_empty_no_panic() {
assert!(
render_element(
&MarkdownElement::Table(TableDef {
headers: vec![],
alignments: vec![],
rows: vec![]
}),
80,
ThemeMode::Dark,
None
)
.is_empty()
);
}
#[test]
fn render_table_html_breaks_as_cell_lines() {
let td = TableDef {
headers: vec!["items".into()],
alignments: vec![Alignment::Left],
rows: vec![vec!["Apple<br>Banana<br />Cherry<BR/>Date".into()]],
};
let r = render_table(&td, 80, false);
assert!(r.iter().any(|line| line.contains("Apple")));
assert!(r.iter().any(|line| line.contains("Banana")));
assert!(r.iter().any(|line| line.contains("Cherry")));
assert!(r.iter().any(|line| line.contains("Date")));
assert!(!r.iter().any(|line| line.contains("<br")));
assert!(!r.iter().any(|line| line.contains("<BR")));
}
#[test]
fn wrap_simple_empty() {
assert_eq!(wrap_simple("", 80), vec![""]);
}
#[test]
fn wrap_simple_wraps() {
assert_eq!(wrap_simple("hello world", 6), vec!["hello", "world"]);
}
#[test]
fn wrap_simple_html_breaks() {
assert_eq!(
wrap_simple("Apple<br>Banana<br />Cherry<BR/>Date", 80),
vec!["Apple", "Banana", "Cherry", "Date"]
);
}
#[test]
fn pad_cell_left() {
assert_eq!(pad_cell("hi", 5, Alignment::Left), " hi ");
}
#[test]
fn pad_cell_right() {
assert_eq!(pad_cell("hi", 5, Alignment::Right), " hi ");
}
}