use crate::highlight::theme;
use crate::terminal::TerminalCaps;
#[derive(Default)]
pub struct MdState {
pub in_code_block: bool,
pub table_buf: Vec<String>,
pub code_buf: Vec<String>,
pub code_lang: Option<String>,
}
impl MdState {
pub fn new() -> Self {
Self::default()
}
pub fn reset(&mut self) {
self.in_code_block = false;
self.table_buf.clear();
self.code_buf.clear();
self.code_lang = None;
}
}
pub fn render_line(line: &str, state: &mut MdState, caps: TerminalCaps) -> Option<String> {
render_line_with_width(line, state, caps, 0)
}
pub fn render_line_with_width(
line: &str,
state: &mut MdState,
caps: TerminalCaps,
max_width: usize,
) -> Option<String> {
let trimmed = line.trim();
if !state.in_code_block && trimmed.starts_with('|') {
state.table_buf.push(trimmed.to_string());
return None;
}
if !state.in_code_block {
if let Some(converted) = box_drawing_table_row(trimmed) {
state.table_buf.push(converted);
return None;
}
}
let prefix = if !state.table_buf.is_empty() {
let t = flush_aligned_table_with_width(&state.table_buf, caps, max_width);
state.table_buf.clear();
Some(t)
} else {
None
};
let prepend = |body: String| -> String {
match prefix.as_ref() {
Some(p) => format!("{}\n{}", p, body),
None => body,
}
};
let prefix_only = || -> Option<String> { prefix.as_ref().map(|p| p.clone()) };
if is_fence(trimmed) {
if state.in_code_block {
let source = state.code_buf.join("\n");
let highlighted = crate::highlight::highlight_block(
state.code_lang.as_deref(),
&source,
caps,
);
state.in_code_block = false;
state.code_buf.clear();
state.code_lang = None;
return Some(prepend(highlighted));
} else {
state.in_code_block = true;
state.code_lang = parse_fence_lang(trimmed);
state.code_buf.clear();
return prefix_only();
}
}
if state.in_code_block {
state.code_buf.push(line.to_string());
return prefix_only();
}
if is_hrule(trimmed) {
return Some(prepend(String::new()));
}
if let Some((level, rest)) = parse_heading(line) {
let inner = render_inline(rest, caps);
let body = if !caps.colors {
format!("{} {}", "#".repeat(level as usize), inner)
} else {
match level {
1 | 2 | 3 => format!("{}{}{}", theme::md_heading_open(), inner, theme::MD_HEADING_CLOSE),
_ => format!("{}{}{}", theme::MD_ITALIC_OPEN, inner, theme::MD_ITALIC_CLOSE),
}
};
return Some(prepend(body));
}
if let Some(item) = parse_list_item(line) {
let inner = render_inline(&item.rest, caps);
let indent = " ".repeat(item.indent);
let body = if caps.colors {
format!(
"{}{}{}{}{}",
indent, theme::MD_MUTED_OPEN, item.marker, theme::MD_MUTED_CLOSE, inner
)
} else {
format!("{}{} {}", indent, item.marker, inner)
};
return Some(prepend(body));
}
Some(prepend(render_inline(line, caps)))
}
pub fn finalize(state: &mut MdState, caps: TerminalCaps) -> Option<String> {
finalize_with_width(state, caps, 0)
}
pub fn finalize_with_width(
state: &mut MdState,
caps: TerminalCaps,
max_width: usize,
) -> Option<String> {
let table_part = if !state.table_buf.is_empty() {
let t = flush_aligned_table_with_width(&state.table_buf, caps, max_width);
state.table_buf.clear();
Some(t)
} else {
None
};
let code_part = if state.in_code_block && !state.code_buf.is_empty() {
let source = state.code_buf.join("\n");
let highlighted = crate::highlight::highlight_block(
state.code_lang.as_deref(),
&source,
caps,
);
state.in_code_block = false;
state.code_buf.clear();
state.code_lang = None;
Some(highlighted)
} else {
None
};
match (table_part, code_part) {
(None, None) => None,
(Some(t), None) => Some(t),
(None, Some(c)) => Some(c),
(Some(t), Some(c)) => Some(format!("{}\n{}", t, c)),
}
}
fn box_drawing_table_row(trimmed: &str) -> Option<String> {
let first = trimmed.chars().next()?;
match first {
'│' => Some(trimmed.replace('│', "|")),
'┌' | '├' | '└' => {
if trimmed.chars().all(|c| {
matches!(
c,
'─' | '┌' | '┬' | '┐' | '├' | '┼' | '┤' | '└' | '┴' | '┘' | ' '
)
}) {
let converted: String = trimmed
.chars()
.map(|c| match c {
'┌' | '┬' | '┐' | '├' | '┼' | '┤' | '└' | '┴' | '┘' => '|',
'─' => '-',
other => other,
})
.collect();
Some(converted)
} else {
None
}
}
_ => None,
}
}
pub fn flush_aligned_table(rows: &[String], caps: TerminalCaps) -> String {
flush_aligned_table_with_width(rows, caps, 0)
}
pub fn flush_aligned_table_with_width(
rows: &[String],
caps: TerminalCaps,
max_width: usize,
) -> String {
let parsed: Vec<Vec<String>> = rows
.iter()
.map(|r| {
let s = r.trim_start_matches('|').trim_end_matches('|');
s.split('|').map(|c| c.trim().to_string()).collect()
})
.collect();
let is_sep = |row: &[String]| -> bool {
row.iter()
.all(|c| !c.is_empty() && c.chars().all(|ch| matches!(ch, '-' | ':' | ' ')))
};
let ncols = parsed.iter().map(|r| r.len()).max().unwrap_or(0);
if ncols == 0 {
return String::new();
}
let mut col_widths = vec![0usize; ncols];
for row in &parsed {
if is_sep(row) {
continue;
}
for (j, cell) in row.iter().enumerate() {
if j >= ncols {
break;
}
let plain = strip_md_for_width(cell);
let w = crate::width::display_width(&plain);
col_widths[j] = col_widths[j].max(w);
}
}
let natural_row_width: usize = 1 + col_widths.iter().map(|w| w + 3).sum::<usize>();
if max_width > 0 && natural_row_width > max_width {
return render_flat_table(&parsed, caps);
}
let border_on = if caps.colors { theme::MD_MUTED_OPEN } else { "" };
let border_off = if caps.colors { theme::MD_MUTED_CLOSE } else { "" };
let rule = |left: char, mid: char, right: char| -> String {
let mut s = String::new();
s.push_str(border_on);
s.push(left);
for (j, w) in col_widths.iter().enumerate() {
for _ in 0..(w + 2) {
s.push('─');
}
if j + 1 < col_widths.len() {
s.push(mid);
}
}
s.push(right);
s.push_str(border_off);
s
};
let data_rows: Vec<&Vec<String>> = parsed.iter().filter(|r| !is_sep(r)).collect();
let mut out = String::new();
out.push_str(&rule('┌', '┬', '┐'));
out.push('\n');
for (i, row) in data_rows.iter().enumerate() {
out.push_str(border_on);
out.push('│');
out.push_str(border_off);
for (j, w) in col_widths.iter().enumerate() {
let cell = row.get(j).map(|s| s.as_str()).unwrap_or("");
let plain_w = crate::width::display_width(&strip_md_for_width(cell));
let body = render_inline(cell, caps);
out.push(' ');
out.push_str(&body);
let pad = w.saturating_sub(plain_w);
for _ in 0..pad {
out.push(' ');
}
out.push(' ');
out.push_str(border_on);
out.push('│');
out.push_str(border_off);
}
out.push('\n');
if i + 1 < data_rows.len() {
out.push_str(&rule('├', '┼', '┤'));
out.push('\n');
}
}
out.push_str(&rule('└', '┴', '┘'));
out
}
fn render_flat_table(parsed: &[Vec<String>], caps: TerminalCaps) -> String {
let is_sep = |row: &[String]| -> bool {
row.iter()
.all(|c| !c.is_empty() && c.chars().all(|ch| matches!(ch, '-' | ':' | ' ')))
};
let has_sep = parsed.iter().any(|r| is_sep(r));
let mut data_iter = parsed.iter().filter(|r| !is_sep(r));
let headers: Vec<String> = if has_sep {
match data_iter.next() {
Some(h) => h.clone(),
None => return String::new(),
}
} else {
Vec::new()
};
let ncols = parsed.iter().map(|r| r.len()).max().unwrap_or(0);
let mut out = String::new();
let mut first = true;
for row in data_iter {
if !first {
out.push('\n');
}
first = false;
for j in 0..ncols {
let cell = row.get(j).map(|s| s.as_str()).unwrap_or("");
let cell_rendered = render_inline(cell, caps);
if let Some(header) = headers.get(j) {
let h_rendered = render_inline(header, caps);
out.push_str(&h_rendered);
out.push(':');
out.push_str(&cell_rendered);
} else {
out.push_str(&cell_rendered);
}
out.push('\n');
}
}
if out.ends_with('\n') {
out.pop();
}
out
}
fn strip_md_for_width(s: &str) -> String {
s.replace("**", "").replace('`', "")
}
pub fn render_inline_line(line: &str, caps: TerminalCaps) -> String {
render_inline(line, caps)
}
fn render_inline(line: &str, caps: TerminalCaps) -> String {
if !caps.colors {
return line.to_string();
}
let mut out = String::with_capacity(line.len() + 16);
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match c {
'*' => {
if chars.peek() == Some(&'*') {
chars.next();
let mut inner = String::new();
let mut closed = false;
while let Some(&p) = chars.peek() {
if p == '*' {
chars.next();
if chars.peek() == Some(&'*') {
chars.next();
closed = true;
break;
} else {
inner.push('*');
}
} else {
chars.next();
inner.push(p);
}
}
if closed && !inner.is_empty() {
out.push_str(theme::MD_BOLD_OPEN);
out.push_str(&inner);
out.push_str(theme::MD_BOLD_CLOSE);
} else {
out.push_str("**");
out.push_str(&inner);
}
} else {
let mut inner = String::new();
let mut closed = false;
while let Some(&p) = chars.peek() {
chars.next();
if p == '*' {
closed = true;
break;
}
inner.push(p);
}
if closed && !inner.is_empty() {
out.push_str(theme::MD_ITALIC_OPEN);
out.push_str(&inner);
out.push_str(theme::MD_ITALIC_CLOSE);
} else {
out.push('*');
out.push_str(&inner);
}
}
}
'`' => {
let mut inner = String::new();
let mut closed = false;
while let Some(&p) = chars.peek() {
chars.next();
if p == '`' {
closed = true;
break;
}
inner.push(p);
}
if closed && !inner.is_empty() {
out.push_str(theme::md_inline_code_open());
out.push_str(&inner);
out.push_str(theme::MD_INLINE_CODE_CLOSE);
} else {
out.push('`');
out.push_str(&inner);
}
}
_ => out.push(c),
}
}
out
}
fn is_fence(trimmed: &str) -> bool {
let mut chars = trimmed.chars();
match chars.next() {
Some('`') => {
trimmed.len() >= 3 && trimmed.as_bytes()[1] == b'`' && trimmed.as_bytes()[2] == b'`'
}
Some('~') => {
trimmed.len() >= 3 && trimmed.as_bytes()[1] == b'~' && trimmed.as_bytes()[2] == b'~'
}
_ => false,
}
}
fn parse_fence_lang(trimmed: &str) -> Option<String> {
let after = trimmed
.trim_start_matches('`')
.trim_start_matches('~')
.trim();
if after.is_empty() {
None
} else {
Some(after.to_lowercase())
}
}
fn is_hrule(trimmed: &str) -> bool {
if trimmed.len() < 3 {
return false;
}
let first = trimmed.chars().next().unwrap();
if first != '-' && first != '*' && first != '_' {
return false;
}
let mut n = 0;
for c in trimmed.chars() {
if c == first {
n += 1;
} else if !c.is_whitespace() {
return false;
}
}
n >= 3
}
fn parse_heading(line: &str) -> Option<(u8, &str)> {
let line = line.trim_start();
let mut level = 0u8;
for c in line.chars() {
if c == '#' && level < 6 {
level += 1;
} else if level > 0 && c == ' ' {
let content = &line[(level as usize) + 1..];
return Some((level, content));
} else {
return None;
}
}
None
}
struct ParsedListItem {
indent: usize,
marker: String,
rest: String,
}
fn parse_list_item(line: &str) -> Option<ParsedListItem> {
let indent = line.chars().take_while(|c| *c == ' ').count();
let rest = &line[indent..];
if let Some(r) = rest.strip_prefix("- ").or_else(|| rest.strip_prefix("* ")) {
return Some(ParsedListItem {
indent,
marker: "•".to_string(),
rest: r.to_string(),
});
}
let digits_end = rest.chars().take_while(|c| c.is_ascii_digit()).count();
if digits_end > 0 {
let after_digits = &rest[digits_end..];
if let Some(r) = after_digits.strip_prefix(". ") {
let marker = &rest[..digits_end]; return Some(ParsedListItem {
indent,
marker: format!("{}.", marker),
rest: r.to_string(),
});
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::highlight::theme;
use crate::terminal::{EnvView, TerminalCaps};
fn caps() -> TerminalCaps {
TerminalCaps::from_env(EnvView {
is_stdout_tty: true,
term: Some("xterm-256color".to_string()),
colorterm: Some("truecolor".to_string()),
lang: Some("en_US.UTF-8".to_string()),
..Default::default()
})
}
fn plain_caps() -> TerminalCaps {
TerminalCaps::from_env(EnvView {
is_stdout_tty: true,
no_color: true,
term: Some("xterm".to_string()),
lang: Some("en_US.UTF-8".to_string()),
..Default::default()
})
}
#[test]
fn inline_bold() {
assert_eq!(
render_inline_line("**bold**", caps()),
format!("{}bold{}", theme::MD_BOLD_OPEN, theme::MD_BOLD_CLOSE)
);
}
#[test]
fn inline_italic() {
assert_eq!(render_inline_line("*em*", caps()), format!("{}em{}", theme::MD_ITALIC_OPEN, theme::MD_ITALIC_CLOSE));
}
#[test]
fn inline_code() {
let rendered = render_inline_line("`x`", caps());
assert!(
rendered.contains(theme::md_inline_code_open()),
"inline code must open with MD_INLINE_CODE_OPEN: {}",
rendered
);
assert!(
rendered.contains(theme::MD_INLINE_CODE_CLOSE),
"inline code must close with MD_INLINE_CODE_CLOSE: {}",
rendered
);
assert!(
!rendered.contains("\x1b[1;97m"),
"inline code must NOT include bright-white SGR 97: {}",
rendered
);
assert!(
!rendered.contains("\x1b[1;38;2;"),
"inline code must NOT include truecolor RGB: {}",
rendered
);
}
#[test]
fn fenced_code_block_colors_off_renders_plain_indented() {
let mut state = MdState::new();
let _ = render_line("```", &mut state, plain_caps()); assert!(render_line("let x = 1;", &mut state, plain_caps()).is_none());
let out = render_line("```", &mut state, plain_caps()).unwrap();
assert!(
out.contains(" let x = 1;"),
"code body must appear with 2-space indent: {:?}",
out
);
assert!(
!out.contains('\x1b'),
"colors-off must emit zero ANSI bytes: {:?}",
out
);
assert!(!out.contains('│'), "no `│` gutter glyph: {:?}", out);
}
#[test]
fn fenced_code_block_colors_on_emits_truecolor_for_known_lang() {
let mut state = MdState::new();
let _ = render_line("```rust", &mut state, caps());
assert!(render_line("fn main() {}", &mut state, caps()).is_none());
let out = render_line("```", &mut state, caps()).unwrap();
assert!(out.contains(" "), "indent preserved: {:?}", out);
assert!(
out.contains("\x1b[38;2;"),
"expected at least one truecolor SGR, got: {:?}",
out
);
}
#[test]
fn fenced_code_block_unknown_lang_falls_back_to_plain_indent() {
let mut state = MdState::new();
let _ = render_line("```frobnicate", &mut state, caps());
assert!(render_line(r#"x = "hello""#, &mut state, caps()).is_none());
let out = render_line("```", &mut state, caps()).unwrap();
assert!(
out.contains(r#"x = "hello""#),
"unknown-lang body must survive verbatim: {:?}",
out
);
assert!(
!out.contains("\x1b["),
"unknown lang must emit zero ANSI: {:?}",
out
);
}
#[test]
fn plain_pass_through() {
assert_eq!(render_inline_line("**b**", plain_caps()), "**b**");
}
#[test]
fn heading_styled() {
let mut st = MdState::new();
let out = render_line("## Hello", &mut st, caps()).unwrap();
assert!(out.contains("Hello"));
assert!(out.contains(theme::md_heading_open()), "H2 should use MD_HEADING_OPEN, got: {:?}", out);
}
#[test]
fn heading_h4_uses_italic_not_color() {
let mut st = MdState::new();
let out = render_line("#### Sub-deep", &mut st, caps()).unwrap();
assert!(out.contains("Sub-deep"));
assert!(out.contains(theme::MD_ITALIC_OPEN), "H4 should use MD_ITALIC_OPEN, got: {:?}", out);
assert!(!out.contains(theme::md_heading_open()), "H4 must not pick up the H1-H3 heading colour");
}
#[test]
fn heading_plain_keeps_hashes() {
let mut st = MdState::new();
let out = render_line("### Sub", &mut st, plain_caps()).unwrap();
assert_eq!(out, "### Sub");
}
#[test]
fn fence_toggles_state_open_close_with_buffering() {
let mut st = MdState::new();
assert!(render_line("```rust", &mut st, plain_caps()).is_none());
assert!(st.in_code_block);
assert!(render_line("let x = 1;", &mut st, plain_caps()).is_none());
assert!(render_line("**not bold**", &mut st, plain_caps()).is_none());
assert_eq!(st.code_buf.len(), 2);
let out = render_line("```", &mut st, plain_caps()).unwrap();
assert!(out.contains("let x = 1;"));
assert!(
out.contains("**not bold**"),
"inline markdown inside code must be preserved literally: {:?}",
out
);
assert!(!st.in_code_block);
assert!(st.code_buf.is_empty());
}
#[test]
fn hrule_becomes_blank_line() {
let mut st = MdState::new();
let out = render_line("---", &mut st, caps()).unwrap();
assert_eq!(out, "");
}
#[test]
fn list_bullets() {
let mut st = MdState::new();
let out = render_line("- item", &mut st, caps()).unwrap();
assert!(
out.contains(&format!("{}•{}", theme::MD_MUTED_OPEN, theme::MD_MUTED_CLOSE)),
"bullet must use MD_MUTED colour: {:?}",
out
);
assert!(out.contains("item"));
}
#[test]
fn list_bullets_plain_caps_no_ansi() {
let mut st = MdState::new();
let out = render_line("- item", &mut st, plain_caps()).unwrap();
assert_eq!(out, "• item");
}
#[test]
fn list_nested_indent() {
let mut st = MdState::new();
let out = render_line(" - nested", &mut st, caps()).unwrap();
assert!(out.starts_with(&format!(" {}•{}", theme::MD_MUTED_OPEN, theme::MD_MUTED_CLOSE)), "nested bullet with indent: {:?}", out);
}
#[test]
fn ordered_list_single_digit() {
let mut st = MdState::new();
let out = render_line("1. first item", &mut st, caps()).unwrap();
assert!(
out.contains(&format!("{}1.{}", theme::MD_MUTED_OPEN, theme::MD_MUTED_CLOSE)),
"ordered marker must use MD_MUTED colour: {:?}",
out
);
assert!(out.contains("first item"));
}
#[test]
fn ordered_list_double_digit() {
let mut st = MdState::new();
let out = render_line("12. twelfth item", &mut st, caps()).unwrap();
assert!(
out.contains(&format!("{}12.{}", theme::MD_MUTED_OPEN, theme::MD_MUTED_CLOSE)),
"double-digit marker must use MD_MUTED colour: {:?}",
out
);
assert!(out.contains("twelfth item"));
}
#[test]
fn ordered_list_plain_caps_no_ansi() {
let mut st = MdState::new();
let out = render_line("3. third", &mut st, plain_caps()).unwrap();
assert_eq!(out, "3. third");
}
#[test]
fn ordered_list_nested() {
let mut st = MdState::new();
let out = render_line(" 5. nested ordered", &mut st, caps()).unwrap();
assert!(
out.starts_with(&format!(" {}5.{}", theme::MD_MUTED_OPEN, theme::MD_MUTED_CLOSE)),
"nested ordered with indent: {:?}",
out
);
assert!(out.contains("nested ordered"));
}
#[test]
fn number_dot_without_space_is_not_list() {
let mut st = MdState::new();
let out = render_line("3.text", &mut st, caps()).unwrap();
assert!(!out.contains(theme::MD_MUTED_OPEN), "no muted marker: {:?}", out);
assert!(out.contains("3.text"));
}
#[test]
fn cjk_bold() {
assert_eq!(
render_inline_line("**你好**", caps()),
format!("{}你好{}", theme::MD_BOLD_OPEN, theme::MD_BOLD_CLOSE)
);
}
#[test]
fn wide_table_renders_as_box_at_natural_widths() {
let rows = vec![
"| Feature | Status |".to_string(),
"|---------|--------|".to_string(),
"| login | done |".to_string(),
"| signup | wip |".to_string(),
];
let out = flush_aligned_table_with_width(&rows, plain_caps(), 80);
assert!(out.contains('┌'));
assert!(out.contains('│'));
assert!(out.contains('└'));
assert!(out.contains("login"));
assert!(out.contains("signup"));
assert!(!out.contains('…'));
}
#[test]
fn narrow_terminal_falls_back_to_flat_records() {
let rows = vec![
"| 能力 | AtomCode Air | Cursor | Copilot |".to_string(),
"|------|--------------|--------|---------|".to_string(),
"| 开源 | ✅ | ❌ | ❌ |".to_string(),
"| 多语言运行 | ✅ Python+ | 🟡 | ❌ |".to_string(),
];
let out = flush_aligned_table_with_width(&rows, plain_caps(), 40);
assert!(!out.contains('│'), "narrow output must not contain border │");
assert!(!out.contains('┌'), "narrow output must not contain top corner");
assert!(out.contains("AtomCode Air"));
assert!(out.contains("Python+"));
let count_neng_li = out.matches("能力").count();
assert_eq!(count_neng_li, 2, "header `能力` should label both data rows");
let count_cursor = out.matches("Cursor").count();
assert_eq!(count_cursor, 2, "header `Cursor` should label both data rows");
assert!(
out.contains("\n\n"),
"expected blank line between flat records"
);
}
#[test]
fn flat_mode_kicks_in_when_natural_width_exceeds_budget() {
let rows = vec![
"| A | B | C |".to_string(),
"|---|---|---|".to_string(),
"| short | also short | x |".to_string(),
];
let wide = flush_aligned_table_with_width(&rows, plain_caps(), 80);
assert!(wide.contains('│'), "80 cols should render as box");
let narrow = flush_aligned_table_with_width(&rows, plain_caps(), 20);
assert!(!narrow.contains('│'), "20 cols should fall back to flat");
}
#[test]
fn box_drawing_table_collapses_to_flat_when_narrow() {
let mut st = MdState::new();
let lines = [
"┌──────────────┬──────────────────────────────────────────┐",
"│ 场景 │ 作用 │",
"├──────────────┼──────────────────────────────────────────┤",
"│ 多文件并行编辑 │ parallel_edit_files 工具触发时分发给子智能体 │",
"├──────────────┼──────────────────────────────────────────┤",
"│ 弹性预算控制 │ 每个 SubAgent 有初始 4 轮对话预算 │",
"└──────────────┴──────────────────────────────────────────┘",
"", ];
let mut out = String::new();
for line in &lines {
if let Some(r) = render_line_with_width(line, &mut st, plain_caps(), 30) {
out.push_str(&r);
out.push('\n');
}
}
assert!(
!out.contains('┌') && !out.contains('└'),
"narrow box-drawing table must collapse to flat:\n{out}"
);
assert_eq!(
out.matches("场景").count(),
2,
"header `场景` should label each data record:\n{out}"
);
assert_eq!(out.matches("作用").count(), 2);
assert!(out.contains("parallel_edit_files"));
assert!(out.contains("初始 4 轮"));
}
#[test]
fn box_drawing_table_re_renders_as_box_when_fits() {
let mut st = MdState::new();
let lines = [
"┌─────┬─────┐",
"│ a │ b │",
"├─────┼─────┤",
"│ 1 │ 2 │",
"└─────┴─────┘",
"",
];
let mut out = String::new();
for line in &lines {
if let Some(r) = render_line_with_width(line, &mut st, plain_caps(), 80) {
out.push_str(&r);
out.push('\n');
}
}
assert!(out.contains('┌'), "wide terminal should keep box rendering:\n{out}");
assert!(out.contains('└'));
assert!(out.contains("a") && out.contains("2"));
}
#[test]
fn box_drawing_detection_does_not_swallow_prose_with_stray_box_char() {
let mut st = MdState::new();
let line = "├ hello, this is not a table line";
let out = render_line_with_width(line, &mut st, plain_caps(), 80);
assert!(out.is_some(), "prose with stray junction must not buffer");
assert!(st.table_buf.is_empty(), "table_buf must stay empty");
}
#[test]
fn mdstate_default_has_empty_code_buf_and_no_lang() {
let s = MdState::new();
assert!(s.code_buf.is_empty(), "code_buf must start empty");
assert!(s.code_lang.is_none(), "code_lang must start None");
}
#[test]
fn mdstate_reset_clears_code_buf_and_lang() {
let mut s = MdState::new();
s.code_buf.push("dirty".into());
s.code_lang = Some("rust".into());
s.in_code_block = true;
s.reset();
assert!(s.code_buf.is_empty(), "reset must clear code_buf");
assert!(s.code_lang.is_none(), "reset must clear code_lang");
assert!(!s.in_code_block, "reset must clear in_code_block");
}
#[test]
fn fence_open_with_lang_captures_lang_and_buffers_lines() {
let mut st = MdState::new();
assert!(render_line("```rust", &mut st, caps()).is_none());
assert_eq!(st.code_lang.as_deref(), Some("rust"));
assert!(st.in_code_block);
assert!(render_line("let x = 1;", &mut st, caps()).is_none());
assert!(render_line("let y = 2;", &mut st, caps()).is_none());
assert_eq!(st.code_buf.len(), 2);
}
#[test]
fn fence_close_flushes_buffered_block_as_one_chunk() {
let mut st = MdState::new();
assert!(render_line("```rust", &mut st, plain_caps()).is_none());
assert!(render_line("let x = 1;", &mut st, plain_caps()).is_none());
assert!(render_line("let y = 2;", &mut st, plain_caps()).is_none());
let out = render_line("```", &mut st, plain_caps()).expect("close fence flushes");
assert!(out.contains("let x = 1;"));
assert!(out.contains("let y = 2;"));
assert!(out.split('\n').count() >= 2);
assert!(!st.in_code_block);
assert!(st.code_buf.is_empty());
assert!(st.code_lang.is_none());
}
#[test]
fn fence_close_with_colors_produces_truecolor_ansi() {
let mut st = MdState::new();
render_line("```rust", &mut st, caps());
render_line("fn main() {}", &mut st, caps());
let out = render_line("```", &mut st, caps()).unwrap();
assert!(
out.contains("\x1b[38;2;"),
"tinted output must contain a truecolor SGR, got: {:?}",
out
);
}
#[test]
fn fence_close_with_no_color_caps_emits_plain_indent_no_ansi() {
let mut st = MdState::new();
render_line("```rust", &mut st, plain_caps());
render_line("let x = 1;", &mut st, plain_caps());
let out = render_line("```", &mut st, plain_caps()).unwrap();
assert!(out.contains(" let x = 1;"));
assert!(!out.contains('\x1b'), "plain_caps must emit zero ANSI, got: {:?}", out);
}
#[test]
fn fence_open_with_no_lang_tag_buffers_with_none_lang() {
let mut st = MdState::new();
assert!(render_line("```", &mut st, caps()).is_none());
assert_eq!(st.code_lang, None);
assert!(st.in_code_block);
}
#[test]
fn lang_tag_with_trailing_whitespace_is_trimmed() {
let mut st = MdState::new();
render_line("```rust ", &mut st, caps());
assert_eq!(st.code_lang.as_deref(), Some("rust"));
}
#[test]
fn finalize_emits_unclosed_code_block_as_fallback() {
let mut st = MdState::new();
render_line("```rust", &mut st, caps());
render_line("let x = 1;", &mut st, caps());
render_line("let y = 2;", &mut st, caps());
let out = finalize(&mut st, caps()).expect("unclosed block must emit something");
let mut st_plain = MdState::new();
render_line("```rust", &mut st_plain, plain_caps());
render_line("let x = 1;", &mut st_plain, plain_caps());
render_line("let y = 2;", &mut st_plain, plain_caps());
let out_plain = finalize(&mut st_plain, plain_caps()).expect("unclosed block must emit");
assert!(out_plain.contains("let x = 1;"), "got: {:?}", out_plain);
assert!(out_plain.contains("let y = 2;"), "got: {:?}", out_plain);
assert!(!out.is_empty());
assert!(st.code_buf.is_empty());
assert!(!st.in_code_block);
}
#[test]
fn finalize_with_no_active_block_returns_none() {
let mut st = MdState::new();
assert!(finalize(&mut st, caps()).is_none());
}
}