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)); }
for row in &rl {
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);
}
out.push(s);
}
}
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();
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
}
#[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 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 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 "); }
}