// crates/atomcode-tuix/src/markdown.rs
//
// Line-oriented markdown renderer. Handles:
// **bold** / *italic* / `code` (inline)
// # / ## / ### headings
// - / * bullet lists
// ```fenced code blocks``` (state-tracked)
// --- horizontal rules
// Tables are passed through as raw text (pipes show literally).
use crate::highlight::theme;
use crate::terminal::TerminalCaps;
/// Parser state maintained across lines of a streamed response.
#[derive(Default)]
pub struct MdState {
pub in_code_block: bool,
/// Accumulates consecutive `|…|` rows; flushed as an aligned block
/// when a non-table line arrives.
pub table_buf: Vec<String>,
/// Lines accumulated between an opening and closing code fence.
/// Flushed through `highlight::highlight_block` on close fence so
/// the code block appears in one chunk at fence close rather than
/// streaming line-by-line.
pub code_buf: Vec<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();
}
}
/// Render one complete line with block- and inline-level markdown applied.
/// Returns None if the line should be omitted from output (e.g., a fence
/// marker ``` that toggles code-block state but isn't itself visible text).
pub fn render_line(line: &str, state: &mut MdState, caps: TerminalCaps) -> Option<String> {
render_line_with_width(line, state, caps, 0)
}
/// Width-aware variant of [`render_line`]. When `max_width > 0`, a flushed
/// table's column widths are capped so every line fits the budget — otherwise
/// `wrap_cells_to_width` downstream chops long rows and shatters the table's
/// border structure. `max_width = 0` keeps legacy behaviour.
pub fn render_line_with_width(
line: &str,
state: &mut MdState,
caps: TerminalCaps,
max_width: usize,
) -> Option<String> {
let trimmed = line.trim();
// Table row: buffer and defer emit until block ends.
if !state.in_code_block && trimmed.starts_with('|') {
state.table_buf.push(trimmed.to_string());
return None;
}
// Pre-drawn Unicode box-drawing table row (`┌─┬─┐ │ ├─┼─┤ └─┴─┘`).
// Some models — usually weaker ones mimicking earlier-turn output that
// we ourselves rendered — emit tables fully drawn in box characters
// instead of `|`-form markdown. Without detection, those rows fall
// through to the inline-only branch and `push_markdown_body`'s
// wrap-at-cell-level chops them at terminal width, shattering the
// borders (the macOS overflow case in the screenshot). Convert each
// row to the equivalent pipe form (│ → |, ─ → -, junctions → |) and
// route through the same buffer + flush path the `|`-form takes;
// `flush_aligned_table_with_width` then enforces flat-mode fallback
// for narrow terminals exactly like a real markdown table would get.
if !state.in_code_block {
if let Some(converted) = box_drawing_table_row(trimmed) {
state.table_buf.push(converted);
return None;
}
}
// Non-table line arriving after buffered rows: flush as aligned block.
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()) };
// Fenced code block fence (``` or ~~~).
//
// OPEN fence: capture the language tag (e.g., `rust` in ```rust),
// start buffering body lines into `state.code_buf`. We don't emit
// anything for the body until close fence — the syntax highlighter
// needs the whole block at once to classify multi-line strings /
// block comments correctly.
//
// CLOSE fence: flush the buffered block through `highlight::highlight_block`,
// which under plan-0 unconditionally emits 2-space-indented plain text
// (no per-token colour — see that function's doc for the rationale).
if is_fence(trimmed) {
if state.in_code_block {
// CLOSE
let source = state.code_buf.join("\n");
let highlighted = crate::highlight::highlight_block(&source);
state.in_code_block = false;
state.code_buf.clear();
return Some(prepend(highlighted));
} else {
// OPEN — language tag on the fence is ignored under plan-0
// (the highlighter is colour-free regardless of language).
state.in_code_block = true;
state.code_buf.clear();
return prefix_only();
}
}
// Inside code block: buffer the line, defer rendering until close fence.
// No per-line output; the highlighter needs full context.
if state.in_code_block {
state.code_buf.push(line.to_string());
return prefix_only();
}
// Horizontal rule — render as a blank separator line, not a visible
// rule. A horizontal bar overwhelms the surrounding prose; a blank line
// communicates the same thematic break far more gracefully.
if is_hrule(trimmed) {
return Some(prepend(String::new()));
}
// Heading — H1-H3 get bold + bright cyan (Palette::ACCENT, SGR 96)
// so headings sit on their own colour layer above the default-colour
// body. Bright cyan was chosen over bright magenta (BRAND, 95)
// because terminals that remap bright white (97, used by inline code
// and code blocks) to lavender — Catppuccin / Tokyo Night / similar
// — typically remap bright magenta to the same lavender, which
// would collapse heading colour into the inline-code colour.
// Cyan stays hue-distinct on those palettes and on plain ANSI.
// H4+ keeps italic-only so the deep-hierarchy levels still read as
// "weaker than a real heading" without adding a third colour tier.
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));
}
// List (unordered or ordered): `- text` / `* text` / `1. text`
// Marker (• / 1.) rendered in muted gray so it sits quietly next to
// the default-fg body text — visually distinct without adding another
// bright colour tier. The space after the marker keeps readability.
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));
}
// Default: inline-only
Some(prepend(render_inline(line, caps)))
}
/// Emit any still-buffered block (e.g., a table that ended without a
/// following non-table line). Call at stream end.
pub fn finalize(state: &mut MdState, caps: TerminalCaps) -> Option<String> {
finalize_with_width(state, caps, 0)
}
/// Width-aware variant of [`finalize`]. See [`render_line_with_width`].
pub fn finalize_with_width(
state: &mut MdState,
caps: TerminalCaps,
max_width: usize,
) -> Option<String> {
// Two independent buffers can be open at stream end: a table waiting
// for a separator row, or a code block whose close fence never came.
// Both must be emitted so the user doesn't lose content.
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(&source);
state.in_code_block = false;
state.code_buf.clear();
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)),
}
}
/// Recognise a pre-drawn Unicode box-drawing table line and return the
/// equivalent `|`-pipe form so it can join the same buffering path as
/// real markdown tables. Returns None for lines that aren't part of a box
/// table.
///
/// Two row shapes accepted:
/// 1. **Data row** — starts with `│`. Each `│` becomes `|`; cell content
/// passes through unchanged. Caller buffers the result and the
/// existing flush logic splits on `|` and trims as usual.
/// 2. **Border row** — starts with `┌`/`├`/`└` AND every char is in the
/// box-drawing set (`─┌┬┐├┼┤└┴┘`) plus spaces. Junctions become `|`
/// and `─` becomes `-`, producing a `|---|---|`-style separator that
/// `flush_aligned_table_with_width`'s `is_sep` matcher already
/// recognises (its predicate is `[-: ]+` per cell).
///
/// The "every char is box-drawing" guard on border rows defends against
/// false positives: a stray paragraph that happens to begin with `├` for
/// some unrelated reason would NOT match (it has letters too).
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,
}
}
/// Flush a buffered markdown table as a column-aligned block. Computes the
/// max display width per column, pads every cell accordingly, renders with
/// `│`/`┼`/`─` box chars in muted gray. Inline markdown inside cells is
/// honoured.
pub fn flush_aligned_table(rows: &[String], caps: TerminalCaps) -> String {
flush_aligned_table_with_width(rows, caps, 0)
}
/// Split a markdown table row on `|`, honouring:
/// * `` ` ``…`` ` `` inline code spans (pipes inside are literal, not
/// separators — so Rust closures `|a, b|`, pattern alternatives
/// `Foo | Bar`, bash pipes `cat | grep`, Python type unions
/// `int | str` etc. inside backticks stay in one cell)
/// * `\|` escape (standard markdown escape for a literal pipe in
/// a cell)
/// * Leading/trailing `|` delimiters (stripped so the edges aren't
/// turned into empty cells)
///
/// Returns trimmed cell strings with backticks preserved (downstream
/// inline formatter handles them). `\|` outside a code span is
/// rewritten to a literal `|` in the cell so the renderer shows the
/// pipe glyph without the backslash.
fn split_table_row(line: &str) -> Vec<String> {
let bytes = line.as_bytes();
let mut cells: Vec<String> = Vec::new();
let mut current = String::new();
let mut in_code = false;
let mut i = 0;
if !bytes.is_empty() && bytes[0] == b'|' {
i = 1;
}
while i < bytes.len() {
let b = bytes[i];
if b == b'`' {
in_code = !in_code;
current.push('`');
i += 1;
continue;
}
if !in_code && b == b'\\' && i + 1 < bytes.len() && bytes[i + 1] == b'|' {
current.push('|');
i += 2;
continue;
}
if !in_code && b == b'|' {
let rest = &line[i + 1..];
if rest.trim().is_empty() {
cells.push(current.trim().to_string());
current = String::new();
break;
}
cells.push(current.trim().to_string());
current = String::new();
i += 1;
continue;
}
let ch_len = if b < 128 {
1
} else {
let mut len = 1;
while i + len < bytes.len() && (bytes[i + len] & 0xc0) == 0x80 {
len += 1;
}
len
};
current.push_str(&line[i..i + ch_len]);
i += ch_len;
}
if !current.is_empty() || cells.is_empty() {
cells.push(current.trim().to_string());
}
cells
}
/// Width-aware variant. When `max_width > 0` and the table can't fit at its
/// natural column widths, fall back to a flat key/value record format
/// (`header: cell` per line, blank line between rows) so no information is
/// lost to per-cell truncation. `max_width = 0` keeps box-table rendering
/// at natural widths regardless of size.
pub fn flush_aligned_table_with_width(
rows: &[String],
caps: TerminalCaps,
max_width: usize,
) -> String {
// Parse each row honouring code-span pipes + `\|` escape.
let parsed: Vec<Vec<String>> = rows.iter().map(|r| split_table_row(r)).collect();
// Identify separator row(s) — cells match `[-: ]+` only.
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();
}
// Compute natural column widths from non-separator rows. We do NOT cap
// these — the cap-and-truncate-with-… approach the previous code took
// chopped real content out of cells and made wide tables in narrow
// terminals unreadable. Instead, if the natural table doesn't fit, the
// flat-mode fallback below renders every cell in full.
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);
}
}
// Total width of one rendered row at natural widths:
// `│` + per-col ` cell ` + `│` between/after each col
// = 1 + sum(w + 3 for w in col_widths)
// If this exceeds the terminal budget, switch to flat mode.
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);
}
// Bright-black / DarkGrey (SGR 90) — table borders are chrome,
// not content. Cyan (SGR 96) made them collide with the input
// box separator and the inline-code colour, collapsing the
// visual hierarchy. Gray reads as quiet structure and lets
// header text + cell content carry the visual weight.
let border_on = if caps.colors { theme::MD_MUTED_OPEN } else { "" };
let border_off = if caps.colors { theme::MD_MUTED_CLOSE } else { "" };
// Draw a horizontal rule row with given connector characters.
//
// Each inner column reserves `(w + 2)` *visual cells* of border —
// matching the content row's ` <body><pad> ` budget (1 leading space
// + w cells of content/padding + 1 trailing space) so border, content
// and separators align column-by-column.
//
// The dash glyph `─` (U+2500) is East Asian Ambiguous. With
// `cell_char_width('─') == 2` (i.e. `ATOMCODE_CJK_WIDTH=1`), each char
// we push occupies 2 cells, so naively pushing `(w + 2)` chars would
// paint `2 * (w + 2)` cells per column — borders drawn way past the
// content's right edge. Push `ceil((w + 2) / dash_w)` chars instead;
// for `dash_w == 1` (default) this is unchanged, for `dash_w == 2`
// odd widths overshoot by 1 cell — minor cosmetic, far better than
// a 2x stretch.
let dash_w = crate::width::cell_char_width('─').unwrap_or(1).max(1);
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() {
let n_dashes = (w + 2).div_ceil(dash_w);
for _ in 0..n_dashes {
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();
// Top border: ┌─┬─┐
out.push_str(&rule('┌', '┬', '┐'));
out.push('\n');
for (i, row) in data_rows.iter().enumerate() {
// Data row: │ cell │ cell │
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');
// Separator between every pair of rows: ├─┼─┤
if i + 1 < data_rows.len() {
out.push_str(&rule('├', '┼', '┤'));
out.push('\n');
}
}
// Bottom border: └─┴─┘
out.push_str(&rule('└', '┴', '┘'));
out
}
/// Narrow-terminal fallback for tables that can't fit at natural column
/// widths. Each data row is expanded into N lines of `header:cell` (one
/// per column), with a blank line between successive rows. Soft-wrapping
/// of long lines is left to the caller's downstream wrap stage so the
/// terminal width budget is honoured without losing any cell content.
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));
// First non-sep row is treated as headers when a separator exists.
// Without a separator the source isn't a real markdown table (it's
// just `|` lines); fall back to printing every cell with no label.
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');
}
}
// Drop the trailing newline so the caller's `format!("{}\n{}", t, body)`
// doesn't sprinkle an extra blank line after the block.
if out.ends_with('\n') {
out.pop();
}
out
}
/// Strip markdown formatting markers (`**bold**`, `*italic*`, `` `code` ``)
/// to recover the *visible* string `render_inline` would produce, for
/// column-width measurement in table layout.
///
/// MUST mirror `render_inline`'s parsing exactly. Earlier this was a naive
/// `s.replace("**", "").replace('`', "")`, which broke alignment for cells
/// containing markdown-marker characters as **literal content inside an
/// inline-code span**: e.g. `` `src/**/*.ts` `` is rendered by
/// `render_inline` as styled "src/**/*.ts" (`**` is glob-pattern literal,
/// preserved inside the code span), but the old strip removed those `**`
/// too — `plain_w` came back 2 cells short and the right border of that
/// row was painted 2 cells past where the column was supposed to end.
/// Same misalignment in reverse for `*italic*`: render strips the single
/// `*` and emits "italic"; the old strip kept the `*` and overshot
/// `plain_w` by 2. Reported on the markdown tools-listing table where
/// the glob row was visibly nudged 2 cells right vs every other row.
///
/// Walk the same parser `render_inline` uses, emit only the inner text,
/// preserve unclosed markers verbatim (matching render's fallback).
fn strip_md_for_width(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.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(&inner);
} 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(&inner);
} 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(&inner);
} else {
out.push('`');
out.push_str(&inner);
}
}
_ => out.push(c),
}
}
out
}
/// Legacy single-line inline renderer — kept for direct callers (tests,
/// simple assistant lines). Does not track block state.
pub fn render_inline_line(line: &str, caps: TerminalCaps) -> String {
render_inline(line, caps)
}
// ─── Helpers ───
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() {
// Bold + bright cyan (SGR 1;96). Earlier iterations
// used bold-only (`\x1b[1m`), bright-white
// (`\x1b[1;97m`), and truecolor blue-500
// (`\x1b[1;38;2;59;130;246m`). Bold-only was too
// subtle — in long mixed output, inline code
// `path/to/foo.rs` was visually indistinguishable
// from **bold** prose. Bright cyan (96) matches the
// heading and code-block accent colour; it's a 16-colour
// SGR interpreted by the terminal's own theme palette,
// so it adapts to both light and dark backgrounds
// (same reason `Palette::CODE` uses SGR 96). The
// close sequence `\x1b[22;39m` resets both bold
// (SGR 22) and fg (SGR 39) so neither bleeds into
// the next span.
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 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
}
/// Parsed list item: indent level, the marker string (e.g. "•", "1."),
/// and the remaining text after the marker.
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..];
// Unordered: "- text" / "* text"
if let Some(r) = rest.strip_prefix("- ").or_else(|| rest.strip_prefix("* ")) {
return Some(ParsedListItem {
indent,
marker: "•".to_string(),
rest: r.to_string(),
});
}
// Ordered: "1. text" / "12. text" — one or more digits followed by ". "
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]; // "1", "12", etc.
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 split_table_row_handles_pipe_in_inline_code() {
let row = "| 闭包 | `|a, b| b.cmp(&a)` |";
assert_eq!(
split_table_row(row),
vec!["闭包".to_string(), "`|a, b| b.cmp(&a)`".to_string()],
);
}
#[test]
fn split_table_row_handles_multiple_code_spans_with_pipes() {
let row = "| `cat | grep` | `int | str` | result |";
assert_eq!(
split_table_row(row),
vec![
"`cat | grep`".to_string(),
"`int | str`".to_string(),
"result".to_string(),
],
);
}
#[test]
fn split_table_row_honours_backslash_escape() {
let row = "| literal \\| pipe | next |";
assert_eq!(
split_table_row(row),
vec!["literal | pipe".to_string(), "next".to_string()],
);
}
#[test]
fn split_table_row_plain_no_codespan_unchanged() {
let row = "| a | b | c |";
assert_eq!(
split_table_row(row),
vec!["a".to_string(), "b".to_string(), "c".to_string()],
);
}
#[test]
fn split_table_row_no_leading_or_trailing_delim() {
let row = "a | b | c";
assert_eq!(
split_table_row(row),
vec!["a".to_string(), "b".to_string(), "c".to_string()],
);
}
#[test]
fn split_table_row_empty_cell_preserved() {
let row = "| a | | c |";
assert_eq!(
split_table_row(row),
vec!["a".to_string(), "".to_string(), "c".to_string()],
);
}
#[test]
fn split_table_row_with_cjk_content() {
let row = "| 模式匹配 | `Foo | Bar` 多模式 |";
assert_eq!(
split_table_row(row),
vec![
"模式匹配".to_string(),
"`Foo | Bar` 多模式".to_string(),
],
);
}
#[test]
fn flush_aligned_table_rust_closure_renders_two_columns() {
// End-to-end: a table cell containing a Rust closure should
// NOT explode into phantom columns. Before the split_table_row
// fix, `|a, b|` inside the cell got split as separators and
// the body row showed 4+ vertical bars (header + 3 phantom
// separators). After the fix, only the real cell separator
// remains: borders + 1 between cell — at most 3 bars total.
let rows = vec![
"| 元素 | 示例 |".to_string(),
"| --- | --- |".to_string(),
"| 闭包 | `|a, b| b.cmp(&a)` |".to_string(),
];
let out = flush_aligned_table_with_width(&rows, plain_caps(), 80);
let body_line = out
.lines()
.find(|l| l.contains("闭包"))
.expect("body row missing");
// Count only the box-drawing vertical `│` (U+2502) — the
// literal ASCII `|` inside the cell content `|a, b|` is
// expected and shouldn't be conflated with column borders.
let box_bar_count = body_line.chars().filter(|c| *c == '│').count();
assert_eq!(
box_bar_count, 3,
"expected exactly 3 box-drawing bars (left border + 1 sep + right border); \
got {} in {:?}",
box_bar_count, body_line,
);
}
#[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() {
// Inline code uses bold + bright cyan. This matches
// the heading and code-block accent colour. The close sequence
// resets both bold and fg.
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() {
// With NO_COLOR / non-TTY caps, code blocks remain plain 2-space-indented
// text with NO ANSI bytes. Pins the no-color invariant.
let mut state = MdState::new();
let _ = render_line("```", &mut state, plain_caps()); // open fence, no lang
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_known_lang_emits_plain_indent_no_ansi() {
// Plan-0 contract (see `highlight_block`'s doc): even with colors
// enabled and a known language tag, the close-fence flush emits
// plain 2-space-indented source with zero ANSI. Per-token
// colouring was dropped because macOS Terminal.app's selection
// overlay made truecolor tokens unreadable inside selections.
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(" fn main() {}"), "indent + body preserved: {:?}", out);
assert!(
!out.contains('\x1b'),
"expected zero ANSI bytes under plan-0, got: {:?}",
out
);
}
#[test]
fn fenced_code_block_unknown_lang_emits_plain_indent() {
// Plan-0: regardless of lang tag (known, unknown, or absent),
// the body is emitted verbatim with a 2-space indent and zero
// ANSI. This test pins the unknown-lang case specifically;
// the known-lang case is covered by
// `fenced_code_block_known_lang_emits_plain_indent_no_ansi`.
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"));
// H1-H3 use the heading colour so they sit on a separate
// colour layer from default-colour body text.
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"));
// H4+ keeps italic-only — distinct from coloured H1-H3 without
// adding a third colour tier.
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() {
// Updated for buffer-and-flush:
// - open fence sets in_code_block, returns None (no body yet)
// - body lines return None and accumulate to code_buf
// - inline markdown inside a buffered line is preserved verbatim
// (we flush as code, not as inline markdown)
// - close fence flushes everything, resets state, returns Some(...)
let mut st = MdState::new();
assert!(render_line("```rust", &mut st, plain_caps()).is_none());
assert!(st.in_code_block);
// Body lines are buffered, not emitted.
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);
// Close fence flushes — final output contains both body lines,
// and the **not bold** markdown is preserved literally (not interpreted).
// Using plain_caps so substring assertions aren't broken by ANSI interleave.
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() {
// Horizontal rules now render as blank lines (thematic break), not
// visible rules — a line of "─" chars is visually noisier than the
// blank separator it's supposed to stand in for.
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();
// Bullet marker rendered in muted colour.
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();
// No colour → plain "• item" without any SGR.
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();
// No colour → plain "3. third" without any SGR.
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() {
// "3.text" (no space after dot) should NOT be parsed as a list item.
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)
);
}
/// Wide-enough terminal: render as a normal box-drawing table at the
/// table's natural column widths. No truncation, no ellipsis.
#[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(),
];
// Plenty of room — natural width is well under 80.
let out = flush_aligned_table_with_width(&rows, plain_caps(), 80);
assert!(out.contains('┌'));
assert!(out.contains('│'));
assert!(out.contains('└'));
// Cell contents survive in full.
assert!(out.contains("login"));
assert!(out.contains("signup"));
// No ellipsis introduced.
assert!(!out.contains('…'));
}
/// Narrow terminal: table can't fit at natural widths → fall back to
/// flat `header:cell` records so no cell content is lost. Mirrors the
/// CC narrow-mode rendering the user requested.
#[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(),
];
// Tight budget — the natural box layout needs > 40 cols.
let out = flush_aligned_table_with_width(&rows, plain_caps(), 40);
// Flat mode: no box-drawing characters anywhere.
assert!(!out.contains('│'), "narrow output must not contain border │");
assert!(!out.contains('┌'), "narrow output must not contain top corner");
// Every cell value survives in full — no truncation.
assert!(out.contains("AtomCode Air"));
assert!(out.contains("Python+"));
// Each header label appears once per data row.
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");
// Records are separated by a blank line.
assert!(
out.contains("\n\n"),
"expected blank line between flat records"
);
}
/// Threshold transition: the same table in a slightly different
/// terminal width should switch modes cleanly.
#[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(),
];
// Natural width ~ 1 + (5+3) + (10+3) + (1+3) = 26.
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");
}
/// Pre-drawn Unicode box-drawing tables (the `┌─┬─┐ │ ├─┼─┤ └─┴─┘`
/// shape some weak models emit instead of `|`-form markdown) must
/// route through the same flat-mode-aware flush path: at narrow widths
/// they collapse to `header:cell` records — no box characters survive.
/// This is the macOS-overflow regression captured in the screenshot.
#[test]
fn box_drawing_table_collapses_to_flat_when_narrow() {
let mut st = MdState::new();
let lines = [
"┌──────────────┬──────────────────────────────────────────┐",
"│ 场景 │ 作用 │",
"├──────────────┼──────────────────────────────────────────┤",
"│ 多文件并行编辑 │ parallel_edit_files 工具触发时分发给子智能体 │",
"├──────────────┼──────────────────────────────────────────┤",
"│ 弹性预算控制 │ 每个 SubAgent 有初始 4 轮对话预算 │",
"└──────────────┴──────────────────────────────────────────┘",
"", // boundary line triggers flush
];
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');
}
}
// Narrow → flat-mode kicks in. No box corners survive.
assert!(
!out.contains('┌') && !out.contains('└'),
"narrow box-drawing table must collapse to flat:\n{out}"
);
// Each header label appears once per data row (2 data rows here).
assert_eq!(
out.matches("场景").count(),
2,
"header `场景` should label each data record:\n{out}"
);
assert_eq!(out.matches("作用").count(), 2);
// Cell content survives in full — no truncation.
assert!(out.contains("parallel_edit_files"));
assert!(out.contains("初始 4 轮"));
}
/// Wide terminal: a box-drawing table re-renders as a clean box at
/// natural widths (the input is converted to pipe form, then
/// `flush_aligned_table_with_width` re-emits its own box drawing).
#[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"));
}
/// False-positive guard: a paragraph whose first character happens to
/// be `├` (or any junction) but has surrounding prose must NOT be
/// pulled into the box-table buffer. The border-row matcher requires
/// the entire trimmed line to consist of box-drawing chars + spaces.
#[test]
fn box_drawing_detection_does_not_swallow_prose_with_stray_box_char() {
let mut st = MdState::new();
// Prose that starts with `├` followed by regular words. Real-world
// probability is near-zero but the guard matters.
let line = "├ hello, this is not a table line";
let out = render_line_with_width(line, &mut st, plain_caps(), 80);
// Must render inline (Some), not buffer (None).
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() {
let s = MdState::new();
assert!(s.code_buf.is_empty(), "code_buf must start empty");
assert!(!s.in_code_block, "in_code_block must start false");
}
#[test]
fn mdstate_reset_clears_code_buf() {
let mut s = MdState::new();
s.code_buf.push("dirty".into());
s.in_code_block = true;
s.reset();
assert!(s.code_buf.is_empty(), "reset must clear code_buf");
assert!(!s.in_code_block, "reset must clear in_code_block");
}
#[test]
fn fence_open_with_lang_tag_buffers_lines_without_emit() {
// Under plan-0 the lang tag is ignored entirely (no language-
// dependent path remains). We only assert that the fence
// transitions state into `in_code_block` and accumulates body
// lines silently until the close fence flushes.
let mut st = MdState::new();
assert!(render_line("```rust", &mut st, caps()).is_none());
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());
// Close fence -> highlighted block returned; state reset.
let out = render_line("```", &mut st, plain_caps()).expect("close fence flushes");
assert!(out.contains("let x = 1;"));
assert!(out.contains("let y = 2;"));
// Output is a single multi-line string (two indented lines + newline between).
assert!(out.split('\n').count() >= 2);
// State is reset for the next block.
assert!(!st.in_code_block);
assert!(st.code_buf.is_empty());
}
#[test]
fn fence_close_with_colors_emits_plain_indent_no_ansi() {
// Plan-0: even with `caps.colors == true`, code-block flush
// emits the body with a 2-space indent and zero ANSI. See
// `highlight::highlight_block`'s doc for why per-token colour
// was dropped (Terminal.app selection-overlay readability).
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'),
"code-block output must contain zero ANSI under plan-0, got: {:?}",
out
);
assert!(out.contains(" fn main() {}"), "indent + body preserved: {:?}", 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() {
let mut st = MdState::new();
assert!(render_line("```", &mut st, caps()).is_none());
assert!(st.in_code_block);
}
#[test]
fn finalize_emits_unclosed_code_block_as_fallback() {
// Stream cuts off before close fence — finalize must still emit
// the buffered body, otherwise the user's last few lines vanish.
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());
// No close fence.
let out = finalize(&mut st, caps()).expect("unclosed block must emit something");
// Under plan-0 the body is emitted verbatim (2-space indent, no
// ANSI), so the source lines are contiguous and substring-checkable
// directly without bouncing through `plain_caps()`.
assert!(out.contains("let x = 1;"), "got: {:?}", out);
assert!(out.contains("let y = 2;"), "got: {:?}", out);
assert!(st.code_buf.is_empty());
assert!(!st.in_code_block);
}
#[test]
fn finalize_with_no_active_block_returns_none() {
// Existing behavior: no buffered table / code → returns None.
let mut st = MdState::new();
assert!(finalize(&mut st, caps()).is_none());
}
// ─── strip_md_for_width vs render_inline alignment ───
//
// For table column-width measurement to be correct, the visible string
// strip_md_for_width returns has to be exactly what render_inline
// ultimately paints (after the SGR escapes are removed). Any divergence
// pads the row by the wrong number of cells and bends the right border
// out of alignment with neighbouring rows.
/// Reported bug: glob row in the tools table painted ~2 cells past the
/// other rows' right border because `` `src/**/*.ts` `` had its inner
/// `**` stripped as bold markers. Pin: `**` inside a code span is
/// literal, never stripped.
#[test]
fn strip_md_for_width_keeps_double_star_inside_inline_code() {
assert_eq!(
strip_md_for_width("`src/**/*.ts`"),
"src/**/*.ts"
);
}
/// Companion to the bug above: `*italic*` is rendered as the inner text
/// only (single `*` stripped by render_inline). Strip must do the same,
/// otherwise plain_w over-counts by 2 cells per italic span.
#[test]
fn strip_md_for_width_strips_single_star_italic_markers() {
assert_eq!(strip_md_for_width("*italic*"), "italic");
}
#[test]
fn strip_md_for_width_strips_double_star_bold_markers() {
assert_eq!(strip_md_for_width("**bold**"), "bold");
}
#[test]
fn strip_md_for_width_strips_backtick_code_markers() {
assert_eq!(strip_md_for_width("`code`"), "code");
}
/// Unclosed markers stay verbatim — matches render_inline's "no closer
/// found, dump the marker as literal" fallback, so column-width math
/// keeps treating them as content.
#[test]
fn strip_md_for_width_keeps_unclosed_markers_verbatim() {
assert_eq!(strip_md_for_width("**unclosed"), "**unclosed");
assert_eq!(strip_md_for_width("*unclosed"), "*unclosed");
assert_eq!(strip_md_for_width("`unclosed"), "`unclosed");
}
/// The literal reported regression input: a CJK cell containing an
/// inline-code span whose inner text has `**` in it. Pre-fix
/// `strip_md_for_width` returned a string two cells short of what
/// `render_inline` paints; post-fix they match exactly.
#[test]
fn strip_md_for_width_matches_render_visible_width_on_glob_cell() {
use crate::width::display_width;
let md = "文件名通配符匹配(如 `src/**/*.ts`)";
let stripped = strip_md_for_width(md);
let rendered = render_inline(md, caps());
// Visible width of render = bytes of render minus SGR escape bytes.
// Easier: re-strip the rendered output by walking past `\x1b[...m`
// chunks; then assert it equals stripped.
let mut visible = String::new();
let bytes = rendered.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b {
while i < bytes.len() && !bytes[i].is_ascii_alphabetic() {
i += 1;
}
if i < bytes.len() {
i += 1; // consume the alpha terminator (m / K / etc.)
}
continue;
}
let next = rendered[i..]
.char_indices()
.nth(1)
.map(|(idx, _)| idx + i)
.unwrap_or(rendered.len());
visible.push_str(&rendered[i..next]);
i = next;
}
assert_eq!(stripped, visible, "strip must equal render-visible");
assert_eq!(display_width(&stripped), display_width(&visible));
}
}