grok-build-upstream-mirror/crates/codegen/xai-grok-markdown/src/render.rs

2753 lines
112 KiB
Rust
Raw Normal View History

//! Markdown renderer - transforms parsed markdown buffers into styled output.
//!
//! After parsing with `MarkdownParser`, use `ParsedMarkdown` to render
//! to either ratatui Lines or ANSI strings.
use std::borrow::Cow;
use std::collections::BTreeSet;
use std::fmt::Write as FmtWrite;
use std::ops::Range;
use anstyle::{Effects, Reset, Style};
use ratatui::text::{Line, Span};
use syntect::highlighting::Style as SyntectStyle;
use crate::buffers::{MarkdownBuffers, RenderEvent, RenderEventKind, unicode_display_width};
use crate::checkpoint::Checkpoint;
use crate::colors::adapt_style;
use crate::hyperlinks::{ChunkLinkRange, chunk_link_offsets, emit_segment_hyperlinks};
use crate::output::{HyperlinkTarget, MarkdownRenderOutput};
use crate::parse::ParsedMarkdown;
use crate::source_map::SourceMap;
use crate::style::{all_hidden, merge_styles};
/// Trait for converting anstyle to ratatui style.
trait StyleInto<T> {
fn style_into(self) -> T;
}
impl StyleInto<ratatui::style::Style> for Style {
fn style_into(self) -> ratatui::style::Style {
use ratatui::style::{Modifier, Style as RStyle};
let mut style = RStyle::default();
if let Some(fg) = self.get_fg_color() {
style = style.fg(anstyle_to_ratatui_color(fg));
}
if let Some(bg) = self.get_bg_color() {
style = style.bg(anstyle_to_ratatui_color(bg));
}
let effects = self.get_effects();
let mut modifiers = Modifier::empty();
if effects.contains(Effects::BOLD) {
modifiers |= Modifier::BOLD;
}
if effects.contains(Effects::DIMMED) {
modifiers |= Modifier::DIM;
}
if effects.contains(Effects::ITALIC) {
modifiers |= Modifier::ITALIC;
}
if effects.contains(Effects::UNDERLINE) {
modifiers |= Modifier::UNDERLINED;
}
if effects.contains(Effects::STRIKETHROUGH) {
modifiers |= Modifier::CROSSED_OUT;
}
if effects.contains(Effects::HIDDEN) {
modifiers |= Modifier::HIDDEN;
}
style.add_modifier(modifiers)
}
}
fn anstyle_to_ratatui_color(color: anstyle::Color) -> ratatui::style::Color {
use ratatui::style::Color;
match color {
anstyle::Color::Ansi(ansi) => match ansi {
anstyle::AnsiColor::Black => Color::Black,
anstyle::AnsiColor::Red => Color::Red,
anstyle::AnsiColor::Green => Color::Green,
anstyle::AnsiColor::Yellow => Color::Yellow,
anstyle::AnsiColor::Blue => Color::Blue,
anstyle::AnsiColor::Magenta => Color::Magenta,
anstyle::AnsiColor::Cyan => Color::Cyan,
anstyle::AnsiColor::White => Color::Gray,
anstyle::AnsiColor::BrightBlack => Color::DarkGray,
anstyle::AnsiColor::BrightRed => Color::LightRed,
anstyle::AnsiColor::BrightGreen => Color::LightGreen,
anstyle::AnsiColor::BrightYellow => Color::LightYellow,
anstyle::AnsiColor::BrightBlue => Color::LightBlue,
anstyle::AnsiColor::BrightMagenta => Color::LightMagenta,
anstyle::AnsiColor::BrightCyan => Color::LightCyan,
anstyle::AnsiColor::BrightWhite => Color::White,
},
anstyle::Color::Ansi256(idx) => Color::Indexed(idx.index()),
anstyle::Color::Rgb(rgb) => Color::Rgb(rgb.0, rgb.1, rgb.2),
}
}
/// Render raw highlighted spans to an ANSI string.
fn render_replace_ansi(highlighted: &[Vec<(SyntectStyle, String)>]) -> String {
let mut out = String::new();
for line_spans in highlighted {
for (style, text) in line_spans {
if text.is_empty() {
continue;
}
let full_style = anstyle_syntect::to_anstyle(*style);
let fg_only = full_style.bg_color(None);
let adapted = adapt_style(fg_only);
if adapted != Style::new() {
write!(out, "{adapted}{text}\x1b[0m").ok();
} else {
out.push_str(text);
}
}
}
out
}
/// Stylize trait for ANSI rendering.
trait Stylize {
fn astyle(&self, style: Style) -> StyledStr<'_>;
}
impl Stylize for str {
fn astyle(&self, style: Style) -> StyledStr<'_> {
StyledStr { text: self, style }
}
}
struct StyledStr<'a> {
text: &'a str,
style: Style,
}
impl<'a> std::fmt::Display for StyledStr<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.style.is_plain() {
write!(f, "{}", self.text)
} else {
write!(f, "{}{}\x1b[0m", self.style, self.text)
}
}
}
impl<'a, 'b> ParsedMarkdown<'a, 'b> {
fn apply_transforms<'t>(&self, text: &'t str, start: usize, pretty: bool) -> Cow<'t, str> {
if self.buffers.transforms.is_empty() {
return Cow::Borrowed(text);
}
// Raw mode applies only `force` transforms (e.g. soft-break collapse).
if !pretty && !self.buffers.transforms.iter().any(|t| t.force) {
return Cow::Borrowed(text);
}
let end = start + text.len();
let mut result = String::new();
let mut pos = start;
let mut applied = false;
for transform in &self.buffers.transforms {
if transform.range.end <= start || transform.range.start >= end {
continue;
}
if !pretty && !transform.force {
continue;
}
applied = true;
// Clamp transform range to our text range
let t_start = transform.range.start.max(start);
let t_end = transform.range.end.min(end);
// Copy text before transform
if t_start > pos {
let before = &text[(pos - start)..(t_start - start)];
result.push_str(before);
}
// Apply transform
result.push_str(&transform.to);
pos = t_end;
}
if !applied {
Cow::Borrowed(text)
} else {
// Copy remaining text
if pos < end {
result.push_str(&text[(pos - start)..]);
}
Cow::Owned(result)
}
}
/// Build sorted render events into the provided Vec.
fn build_render_events_into(&self, events: &mut Vec<RenderEvent>) {
events.clear();
let capacity = self.buffers.highlights.len() * 2
+ self.buffers.replaces.len() * 2
+ self.buffers.table_replaces.len() * 2
+ self.buffers.mermaid_replaces.len() * 2;
events.reserve(capacity);
for (i, hl) in self.buffers.highlights.iter().enumerate() {
events.push(RenderEvent {
pos: hl.range.start,
kind: RenderEventKind::Highlight,
index: i,
is_end: false,
});
events.push(RenderEvent {
pos: hl.range.end,
kind: RenderEventKind::Highlight,
index: i,
is_end: true,
});
}
for (i, r) in self.buffers.replaces.iter().enumerate() {
events.push(RenderEvent {
pos: r.range.start,
kind: RenderEventKind::Replace,
index: i,
is_end: false,
});
events.push(RenderEvent {
pos: r.range.end,
kind: RenderEventKind::Replace,
index: i,
is_end: true,
});
}
for (i, t) in self.buffers.table_replaces.iter().enumerate() {
events.push(RenderEvent {
pos: t.range.start,
kind: RenderEventKind::Table,
index: i,
is_end: false,
});
events.push(RenderEvent {
pos: t.range.end,
kind: RenderEventKind::Table,
index: i,
is_end: true,
});
}
for (i, m) in self.buffers.mermaid_replaces.iter().enumerate() {
events.push(RenderEvent {
pos: m.range.start,
kind: RenderEventKind::Mermaid,
index: i,
is_end: false,
});
events.push(RenderEvent {
pos: m.range.end,
kind: RenderEventKind::Mermaid,
index: i,
is_end: true,
});
}
events.sort_unstable();
}
/// Build sorted render events into a new Vec.
fn build_render_events(&self) -> Vec<RenderEvent> {
let mut events = Vec::new();
self.build_render_events_into(&mut events);
events
}
/// Render to ANSI-styled string.
///
/// If `pretty` is true, syntax markers are hidden.
/// Returns the rendered string and a source map for copy-paste support.
pub fn render_ansi(&mut self, pretty: bool) -> (String, SourceMap) {
let events = self.build_render_events();
// Apply force transforms in place over a copy of `self.text` so
// the ANSI path picks them up without restructuring `push`. See
// `Transform::force` for the byte-length invariant.
let text_owned: Option<String> = if self.buffers.transforms.iter().any(|t| t.force) {
let mut bytes = self.text.as_bytes().to_vec();
for t in &self.buffers.transforms {
if !t.force {
continue;
}
debug_assert_eq!(
t.to.len(),
t.range.end - t.range.start,
"force transforms must preserve byte length",
);
debug_assert!(
self.text.is_char_boundary(t.range.start)
&& self.text.is_char_boundary(t.range.end),
"force transform range must align with char boundaries",
);
bytes[t.range.clone()].copy_from_slice(t.to.as_bytes());
}
Some(String::from_utf8(bytes).expect("force transforms preserve UTF-8"))
} else {
None
};
let view_text: &str = text_owned.as_deref().unwrap_or(self.text);
let mut out = String::with_capacity(view_text.len() * 2);
let mut source_map = SourceMap::new();
let mut rendered_offset = 0;
let mut hl_ids = BTreeSet::<usize>::new();
let mut last_pos = 0;
let mut replace: Option<usize> = None;
let mut table_replace: Option<usize> = None;
let mut mermaid_replace: Option<usize> = None;
let mut current = (0..0, Style::new());
fn push(
out: &mut String,
current: &mut (Range<usize>, Style),
text: &str,
range: Range<usize>,
style: Style,
source_map: &mut SourceMap,
rendered_offset: &mut usize,
) {
let (crange, cstyle) = current;
let ctext = &text[crange.clone()];
if !range.is_empty() && style == *cstyle {
if !ctext.is_empty() {
debug_assert_eq!(crange.end, range.start);
}
crange.end = range.end;
return;
}
if !ctext.is_empty() {
source_map.add(*rendered_offset, crange.clone());
*rendered_offset += ctext.len();
if cstyle.is_plain() {
out.push_str(ctext);
} else {
out.push_str(&ctext.astyle(*cstyle).to_string());
}
}
*crange = range;
*cstyle = style;
}
for ev in &events {
if replace.is_none()
&& table_replace.is_none()
&& mermaid_replace.is_none()
&& ev.pos > last_pos
{
let should_skip =
pretty && all_hidden(hl_ids.iter().map(|&i| self.buffers.highlights[i].style));
if should_skip {
push(
&mut out,
&mut current,
view_text,
ev.pos..ev.pos,
Style::new(),
&mut source_map,
&mut rendered_offset,
);
} else {
let mut style =
merge_styles(hl_ids.iter().map(|&i| self.buffers.highlights[i].style));
let text = &view_text[last_pos..ev.pos];
let is_invert = style.get_effects().contains(Effects::INVERT);
if text.as_bytes().iter().all(|&ch| ch == b'\n')
|| (text.as_bytes().iter().all(u8::is_ascii_whitespace)
&& ((!is_invert && style.get_bg_color().is_none())
|| (is_invert && style.get_fg_color().is_none())))
{
style = Style::new();
}
push(
&mut out,
&mut current,
view_text,
last_pos..ev.pos,
style,
&mut source_map,
&mut rendered_offset,
);
}
last_pos = ev.pos;
}
match ev.kind {
RenderEventKind::Replace => {
if ev.is_end && replace == Some(ev.index) {
replace = None;
out.push_str(&Reset.to_string());
} else if !ev.is_end && replace.is_none() && table_replace.is_none() {
replace = Some(ev.index);
push(
&mut out,
&mut current,
view_text,
ev.pos..ev.pos,
Style::new(),
&mut source_map,
&mut rendered_offset,
);
out.push_str(&Reset.to_string());
let repl = &self.buffers.replaces[ev.index];
let ansi_content = render_replace_ansi(&repl.highlighted);
let replace_text_len: usize = repl
.highlighted
.iter()
.flat_map(|line| line.iter().map(|(_, t)| t.len()))
.sum();
source_map.add(rendered_offset, repl.range.clone());
rendered_offset += replace_text_len;
out.push_str(&ansi_content);
last_pos = repl.range.end;
}
}
RenderEventKind::Table => {
if ev.is_end && table_replace == Some(ev.index) {
table_replace = None;
} else if !ev.is_end && table_replace.is_none() && pretty {
table_replace = Some(ev.index);
push(
&mut out,
&mut current,
view_text,
ev.pos..ev.pos,
Style::new(),
&mut source_map,
&mut rendered_offset,
);
let trepl = &self.buffers.table_replaces[ev.index];
// Block lines must start at a line boundary; a
// display-math replacement can occur mid-paragraph.
// Styled chunks end with a reset sequence after the
// newline, so check both forms.
let at_line_start =
out.is_empty() || out.ends_with('\n') || out.ends_with("\n\x1b[0m");
if !at_line_start {
out.push('\n');
rendered_offset += 1;
}
for line in &trepl.lines {
out.push_str(line);
out.push('\n');
rendered_offset += line.len() + 1;
}
last_pos = trepl.range.end;
// Advance `current` past the table so trailing text
// doesn't merge back to the pre-table position.
current.0 = trepl.range.end..trepl.range.end;
}
}
RenderEventKind::Mermaid => {
if ev.is_end && mermaid_replace == Some(ev.index) {
mermaid_replace = None;
} else if !ev.is_end && mermaid_replace.is_none() && pretty {
mermaid_replace = Some(ev.index);
push(
&mut out,
&mut current,
view_text,
ev.pos..ev.pos,
Style::new(),
&mut source_map,
&mut rendered_offset,
);
let mrepl = &self.buffers.mermaid_replaces[ev.index];
for line in &mrepl.lines {
out.push_str(line);
out.push('\n');
rendered_offset += line.len() + 1;
}
last_pos = mrepl.range.end;
current.0 = mrepl.range.end..mrepl.range.end;
}
}
RenderEventKind::Highlight => {
if ev.is_end {
hl_ids.remove(&ev.index);
} else {
hl_ids.insert(ev.index);
}
}
}
}
let len = view_text.len();
if last_pos < len {
push(
&mut out,
&mut current,
view_text,
last_pos..len,
Style::new(),
&mut source_map,
&mut rendered_offset,
);
}
push(
&mut out,
&mut current,
view_text,
len..len,
Style::new(),
&mut source_map,
&mut rendered_offset,
);
(out, source_map)
}
/// Render to ratatui Lines.
///
/// If `pretty` is true, syntax markers are hidden.
/// Returns rendered lines, line source map, and optional checkpoint.
pub fn render_ratatui(&mut self, pretty: bool) -> (MarkdownRenderOutput, Option<Checkpoint>) {
// Build render events
let render_events = self.build_render_events();
self.buffers.current_spans.clear();
self.buffers.active_highlights.clear();
let mut lines: Vec<Line<'static>> = Vec::new();
let mut line_source_map: Vec<usize> = Vec::new();
let mut hyperlinks: Vec<HyperlinkTarget> = Vec::new();
let mut last_pos = 0;
let mut replace: Option<usize> = None;
let mut table_replace: Option<usize> = None;
let mut mermaid_replace: Option<usize> = None;
let mut skip_leading_newline = false;
let mut in_hidden_code_block = false;
let mut next_link_idx: usize = 0;
// Running display-column tracker for the in-progress line.
let mut cur_col_in_line: usize = 0;
let checkpoint_info = self.last_checkpoint;
let mut checkpoint_output_lines: Option<usize> = None;
// Style already adapted - no need to call adapt_style again
let code_bg_style: ratatui::style::Style = self.ms.code_background.style_into();
let in_untagged_code = |pos: usize, buffers: &MarkdownBuffers| -> bool {
buffers
.untagged_code_ranges
.iter()
.any(|range| pos >= range.start && pos < range.end)
};
let mut current_source_line = 0usize;
let mut last_line_count_pos = 0usize;
let mut pending_line_is_code = false;
let count_newlines_in_range = |from: usize, to: usize, text: &str| -> usize {
if to <= from {
return 0;
}
let to = to.min(text.len());
let from = from.min(to);
// Use as_bytes() to avoid panicking on non-char-boundary offsets.
// This is safe because '\n' (0x0A) is a single-byte ASCII value
// that can never appear as a UTF-8 continuation byte (0x80..0xBF).
text.as_bytes()[from..to]
.iter()
.filter(|&&b| b == b'\n')
.count()
};
for ev in &render_events {
if replace.is_none()
&& table_replace.is_none()
&& mermaid_replace.is_none()
&& ev.pos > last_pos
{
// Check if we need to split text processing at the checkpoint boundary.
// If last_pos < cp_byte <= ev.pos, we process in two parts:
// 1. Process [last_pos..cp_byte], capture lines.len(), process [cp_byte..ev.pos]
let split_at_checkpoint = checkpoint_output_lines.is_none()
&& checkpoint_info
.map(|(_, cp_byte)| last_pos < cp_byte && cp_byte <= ev.pos)
.unwrap_or(false);
let cp_byte = checkpoint_info.map(|(_, cp)| cp).unwrap_or(0);
// Snap cp_byte to the nearest char boundary. Checkpoint byte
// offsets come from pulldown-cmark event ranges which should
// always be char-aligned, but in edge cases (e.g., thematic
// breaks followed by headings with multi-byte chars) the
// position can land mid-character. Snapping forward is safe
// because it only affects where we split the text for line
// counting — a few extra or fewer newlines in the first vs
// second range doesn't change the total count.
let cp_byte = {
let mut b = cp_byte;
while b < self.text.len() && !self.text.is_char_boundary(b) {
b += 1;
}
b
};
// Determine ranges to process
let ranges: &[(usize, usize)] = if split_at_checkpoint {
// Process in two parts, capturing checkpoint between them
&[(last_pos, cp_byte), (cp_byte, ev.pos)]
} else {
// Process as single range
&[(last_pos, ev.pos)]
};
for (range_idx, &(range_start, range_end)) in ranges.iter().enumerate() {
// After processing the first range when splitting, capture checkpoint.
// Flush any pending spans to `lines` first — content like a thematic
// break (`───`) may sit in `current_spans` without a trailing newline
// to flush it. Without this flush, the checkpoint's `output_lines`
// count would be too low, causing the line to vanish on re-render.
if split_at_checkpoint && range_idx == 1 {
if !self.buffers.current_spans.is_empty() {
line_source_map.push(current_source_line);
let line = Line::from(std::mem::take(&mut self.buffers.current_spans));
lines.push(line);
cur_col_in_line = 0;
}
checkpoint_output_lines = Some(lines.len());
}
if range_end <= range_start {
continue;
}
// Update source line counter
if range_start > last_line_count_pos {
current_source_line +=
count_newlines_in_range(last_line_count_pos, range_start, self.text);
last_line_count_pos = range_start;
}
let is_hidden = pretty
&& all_hidden(
self.buffers
.active_highlights
.iter()
.map(|&i| self.buffers.highlights[i].style),
);
if is_hidden {
let at_line_start = range_start == 0
|| self.text.as_bytes().get(range_start - 1) == Some(&b'\n');
if at_line_start {
// Check if this hidden block is a code fence (``` or ~~~).
// Only code fences need separator handling — heading markers
// (#) are also hidden at line start but are unpaired.
let hidden_text = self.text[range_start..range_end].trim_start();
let is_code_fence =
hidden_text.starts_with("```") || hidden_text.starts_with("~~~");
if is_code_fence {
// Emit a blank separator before an OPENING fence (not
// closing). Prevents adjacent blocks (e.g., list → code)
// from collapsing their visual boundary when the hidden
// fence markers are removed in pretty mode.
if !in_hidden_code_block
&& lines.last().is_some_and(|l| l.width() > 0)
{
line_source_map.push(current_source_line);
lines.push(Line::default());
cur_col_in_line = 0;
}
in_hidden_code_block = !in_hidden_code_block;
}
skip_leading_newline = true;
}
} else {
let mut text = &self.text[range_start..range_end];
let mut text_start = range_start;
if skip_leading_newline && text.starts_with('\n') {
text = &text[1..];
text_start += 1;
}
skip_leading_newline = false;
if !text.is_empty() {
let style = merge_styles(
self.buffers
.active_highlights
.iter()
.map(|&i| self.buffers.highlights[i].style),
);
let transformed = self.apply_transforms(text, range_start, pretty);
let ratatui_style: ratatui::style::Style = style.style_into();
let chunk_src_start = text_start;
let chunk_src_end = text_start + text.len();
// Advance the cursor past links that ended before
// this chunk starts, then check if any remaining
// link overlaps the chunk. Skip all hyperlink
// bookkeeping when none does — keeps the no-link
// hot path identical to the pre-feature renderer.
while next_link_idx < self.buffers.link_targets.len()
&& self.buffers.link_targets[next_link_idx].source_range.end
<= chunk_src_start
{
next_link_idx += 1;
}
let chunk_has_links = next_link_idx < self.buffers.link_targets.len()
&& self.buffers.link_targets[next_link_idx].source_range.start
< chunk_src_end;
let chunk_links: Vec<ChunkLinkRange> = if chunk_has_links {
chunk_link_offsets(
&self.buffers.link_targets,
next_link_idx,
chunk_src_start,
chunk_src_end,
pretty,
&self.buffers.transforms,
)
} else {
Vec::new()
};
let mut byte_offset = text_start;
let mut seg_x_offset: usize = 0;
let is_in_code = in_untagged_code(text_start, self.buffers);
pending_line_is_code = is_in_code;
for (idx, segment) in transformed.split('\n').enumerate() {
if idx > 0 {
line_source_map.push(current_source_line);
let line =
Line::from(std::mem::take(&mut self.buffers.current_spans));
lines.push(if is_in_code {
line.style(code_bg_style)
} else {
line
});
if byte_offset > last_line_count_pos {
current_source_line += count_newlines_in_range(
last_line_count_pos,
byte_offset,
self.text,
);
last_line_count_pos = byte_offset;
}
cur_col_in_line = 0;
}
if !chunk_links.is_empty() {
emit_segment_hyperlinks(
&chunk_links,
&self.buffers.link_targets,
segment,
seg_x_offset,
cur_col_in_line,
lines.len(),
&mut hyperlinks,
);
}
if !segment.is_empty() {
self.buffers
.current_spans
.push(Span::styled(segment.to_string(), ratatui_style));
cur_col_in_line += unicode_display_width(segment);
}
byte_offset += segment.len() + 1;
seg_x_offset += segment.len() + 1;
}
}
}
}
last_pos = ev.pos;
}
match ev.kind {
RenderEventKind::Replace => {
if ev.is_end && replace == Some(ev.index) {
replace = None;
} else if !ev.is_end && replace.is_none() && table_replace.is_none() {
replace = Some(ev.index);
let repl = &self.buffers.replaces[ev.index];
// Update source line to code start
if repl.range.start > last_line_count_pos {
current_source_line += count_newlines_in_range(
last_line_count_pos,
repl.range.start,
self.text,
);
}
let code_start_source_line = current_source_line;
for (line_idx, line_spans) in repl.highlighted.iter().enumerate() {
current_source_line = code_start_source_line + line_idx;
for (syn_style, text) in line_spans {
let full_style = anstyle_syntect::to_anstyle(*syn_style);
let with_bg =
full_style.bg_color(self.ms.code_background.get_bg_color());
// This is the only legitimate inline adapt_style call
// for dynamically created syntect+background combo
let adapted = adapt_style(with_bg);
let ratatui_style: ratatui::style::Style = adapted.style_into();
for (idx, segment) in text.split('\n').enumerate() {
if idx > 0 {
line_source_map.push(current_source_line);
let line = Line::from(std::mem::take(
&mut self.buffers.current_spans,
))
.style(code_bg_style);
lines.push(line);
current_source_line += 1;
cur_col_in_line = 0;
}
if !segment.is_empty() {
self.buffers
.current_spans
.push(Span::styled(segment.to_string(), ratatui_style));
cur_col_in_line += unicode_display_width(segment);
}
}
}
if !self.buffers.current_spans.is_empty() {
line_source_map.push(current_source_line);
let line =
Line::from(std::mem::take(&mut self.buffers.current_spans))
.style(code_bg_style);
lines.push(line);
cur_col_in_line = 0;
}
}
last_pos = repl.range.end;
let newlines_in_code =
count_newlines_in_range(repl.range.start, repl.range.end, self.text);
current_source_line = code_start_source_line + newlines_in_code;
last_line_count_pos = repl.range.end;
if checkpoint_output_lines.is_none()
&& let Some((_, cp_byte)) = checkpoint_info
&& last_pos >= cp_byte
{
checkpoint_output_lines = Some(lines.len());
}
}
}
RenderEventKind::Table => {
if ev.is_end && table_replace == Some(ev.index) {
table_replace = None;
} else if !ev.is_end && table_replace.is_none() && pretty {
table_replace = Some(ev.index);
let trepl = &self.buffers.table_replaces[ev.index];
// Flush any in-progress inline spans first. Tables
// always start at a line boundary (no-op), but a
// display-math block replacement can occur
// mid-paragraph (`text $$x$$ more`): without the
// flush, the pending "text " spans would be emitted
// AFTER the block lines.
if !self.buffers.current_spans.is_empty() {
line_source_map.push(current_source_line);
lines.push(Line::from(std::mem::take(&mut self.buffers.current_spans)));
// cur_col_in_line is reset unconditionally after
// the block lines are emitted below.
}
// Update source line to table start
if trepl.range.start > last_line_count_pos {
current_source_line += count_newlines_in_range(
last_line_count_pos,
trepl.range.start,
self.text,
);
}
let table_start_source_line = current_source_line;
let table_base_line = lines.len();
for (line_idx, styled_line) in trepl.styled_lines.iter().enumerate() {
let offset = trepl
.line_source_offsets
.get(line_idx)
.copied()
.unwrap_or(0);
current_source_line = table_start_source_line + offset;
line_source_map.push(current_source_line);
lines.push(styled_line.clone());
}
// Translate table-local hyperlink coordinates into
// absolute line indices and append to the global list.
for link in &trepl.hyperlinks {
hyperlinks.push(HyperlinkTarget {
line_index: table_base_line + link.line_offset,
column_range: link.column_range.clone(),
url: link.url.clone(),
id: link.id,
});
}
// Table emits whole pre-rendered lines; reset col so
// any subsequent inline content starts at column 0.
cur_col_in_line = 0;
last_pos = trepl.range.end;
let newlines_in_table =
count_newlines_in_range(trepl.range.start, trepl.range.end, self.text);
current_source_line = table_start_source_line + newlines_in_table;
last_line_count_pos = trepl.range.end;
if checkpoint_output_lines.is_none()
&& let Some((_, cp_byte)) = checkpoint_info
&& last_pos >= cp_byte
{
checkpoint_output_lines = Some(lines.len());
}
}
}
RenderEventKind::Mermaid => {
if ev.is_end && mermaid_replace == Some(ev.index) {
mermaid_replace = None;
} else if !ev.is_end && mermaid_replace.is_none() && pretty {
mermaid_replace = Some(ev.index);
let mrepl = &self.buffers.mermaid_replaces[ev.index];
if mrepl.range.start > last_line_count_pos {
current_source_line += count_newlines_in_range(
last_line_count_pos,
mrepl.range.start,
self.text,
);
}
let start_source_line = current_source_line;
for styled_line in &mrepl.styled_lines {
line_source_map.push(start_source_line);
lines.push(styled_line.clone());
}
cur_col_in_line = 0;
last_pos = mrepl.range.end;
let newlines =
count_newlines_in_range(mrepl.range.start, mrepl.range.end, self.text);
current_source_line = start_source_line + newlines;
last_line_count_pos = mrepl.range.end;
if checkpoint_output_lines.is_none()
&& let Some((_, cp_byte)) = checkpoint_info
&& last_pos >= cp_byte
{
checkpoint_output_lines = Some(lines.len());
}
}
}
RenderEventKind::Highlight => {
if ev.is_end {
self.buffers.active_highlights.retain(|&x| x != ev.index);
} else {
self.buffers.active_highlights.push(ev.index);
}
}
}
}
// Handle remaining text
let len = self.text.len();
if last_pos < len {
// Apply force transforms only; non-force transforms have
// never been applied in this trailing path and force
// transforms preserve byte length so source offsets below
// stay valid.
let raw = &self.text[last_pos..len];
let transformed = self.apply_transforms(raw, last_pos, false);
debug_assert_eq!(transformed.len(), raw.len());
let text: &str = &transformed;
let is_only_whitespace = text.as_bytes().iter().all(u8::is_ascii_whitespace);
if !(pretty && is_only_whitespace) {
if last_pos > last_line_count_pos {
current_source_line +=
count_newlines_in_range(last_line_count_pos, last_pos, self.text);
last_line_count_pos = last_pos;
}
let chunk_src_start = last_pos;
let chunk_src_end = last_pos + text.len();
// Same cursor-skip pattern as the main path: keep the no-link
// hot path identical to the pre-feature renderer.
while next_link_idx < self.buffers.link_targets.len()
&& self.buffers.link_targets[next_link_idx].source_range.end <= chunk_src_start
{
next_link_idx += 1;
}
let chunk_has_links = next_link_idx < self.buffers.link_targets.len()
&& self.buffers.link_targets[next_link_idx].source_range.start < chunk_src_end;
// Trailing text bypasses apply_transforms (it's emitted raw),
// so transformed offsets equal source offsets within the chunk.
let chunk_links: Vec<ChunkLinkRange> = if chunk_has_links {
chunk_link_offsets(
&self.buffers.link_targets,
next_link_idx,
chunk_src_start,
chunk_src_end,
false,
&[],
)
} else {
Vec::new()
};
let mut byte_offset = last_pos;
let mut seg_x_offset: usize = 0;
let is_in_code = in_untagged_code(last_pos, self.buffers);
pending_line_is_code = is_in_code;
for (idx, segment) in text.split('\n').enumerate() {
if idx > 0 {
line_source_map.push(current_source_line);
let line = Line::from(std::mem::take(&mut self.buffers.current_spans));
lines.push(if is_in_code {
line.style(code_bg_style)
} else {
line
});
if byte_offset > last_line_count_pos {
current_source_line += count_newlines_in_range(
last_line_count_pos,
byte_offset,
self.text,
);
last_line_count_pos = byte_offset;
}
cur_col_in_line = 0;
}
if !chunk_links.is_empty() {
emit_segment_hyperlinks(
&chunk_links,
&self.buffers.link_targets,
segment,
seg_x_offset,
cur_col_in_line,
lines.len(),
&mut hyperlinks,
);
}
if !segment.is_empty() {
self.buffers
.current_spans
.push(Span::raw(segment.to_string()));
cur_col_in_line += unicode_display_width(segment);
}
byte_offset += segment.len() + 1;
seg_x_offset += segment.len() + 1;
}
}
}
// Emit final line. Use the membership of the chunk that produced these spans:
// an unterminated bare fence ends its range exactly at last_pos (EOF) and the
// range check is end-exclusive, so recomputing here would drop the code bg.
if !self.buffers.current_spans.is_empty() {
line_source_map.push(current_source_line);
let final_is_code = pending_line_is_code;
let line = Line::from(std::mem::take(&mut self.buffers.current_spans));
lines.push(if final_is_code {
line.style(code_bg_style)
} else {
line
});
}
// If checkpoint wasn't captured during event processing, compute it based on
// the number of newlines in the text up to checkpoint byte.
// This handles cases where there are no events past the checkpoint (e.g., incomplete list items).
if checkpoint_output_lines.is_none()
&& let Some((_, cp_byte)) = checkpoint_info
{
// Count newlines in text before the checkpoint byte.
// Each newline ENDS a line, so N newlines = N complete lines.
// However, we need to account for blank lines that are absorbed
// into the block separator. The checkpoint is at the start of
// the NEXT block, so lines from the frozen content should not
// include any content that starts at or after cp_byte.
//
// More precise approach: count how many output lines have their
// content entirely before cp_byte. This is tricky without tracking
// each line's byte range.
//
// Logic:
// - Each newline ENDS a line
// - Use line_source_map to find output lines before checkpoint
// - line_source_map[i] is the source line at which output line i was created
// - source_line_at_cp is the source line containing cp_byte
// - Output lines with source_line < source_line_at_cp are complete before checkpoint
let source_line_at_cp = self.text[..cp_byte.min(self.text.len())]
.bytes()
.filter(|&b| b == b'\n')
.count();
// When the checkpoint is at or past the end of the text, ALL output
// lines belong to the frozen content (the entire input was consumed
// by the checkpointed block). Otherwise, output lines created at
// source lines strictly before the checkpoint source line are frozen.
let complete_lines = if cp_byte >= self.text.len() {
lines.len()
} else {
line_source_map
.iter()
.take_while(|&&src_line| src_line < source_line_at_cp)
.count()
};
checkpoint_output_lines = Some(complete_lines.min(lines.len()));
}
let checkpoint = match (checkpoint_info, checkpoint_output_lines) {
(Some((kind, source_bytes)), Some(output_lines)) => Some(Checkpoint {
source_bytes,
output_lines,
kind,
}),
_ => None,
};
// Now that `line_source_map` is final, map each parsed code block's
// body onto its rendered (pre-wrap) line range.
let text = self.text;
let code_blocks = crate::output::build_code_block_spans(
text,
&line_source_map,
std::mem::take(&mut self.buffers.code_blocks),
);
(
MarkdownRenderOutput {
lines,
line_source_map,
hyperlinks,
code_blocks,
},
checkpoint,
)
}
}
#[cfg(test)]
mod tests {
use crate::render_markdown_ratatui_full;
use crate::style::test_style;
fn lines_to_text(lines: &[ratatui::text::Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
}
/// A fenced `mermaid` block renders as a diagram in pretty mode.
#[test]
fn test_mermaid_block_renders_diagram() {
let md = "```mermaid\ngraph TD\n A[Start] --> B[End]\n```\n";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None);
let text = lines_to_text(&output.lines).join("\n");
assert!(
text.contains('┌') || text.contains('╭'),
"expected box-drawing, got:\n{text}"
);
assert!(text.contains("Start") && text.contains("End"), "{text}");
assert!(text.contains('▼'), "expected an arrowhead, got:\n{text}");
assert!(!text.contains("```"), "fences should be hidden:\n{text}");
}
/// A mermaid fence with trailing info tokens still renders a diagram.
#[test]
fn test_mermaid_block_with_info_extras_renders() {
let md = "```mermaid theme=dark\ngraph TD\n A[X] --> B[Y]\n```\n";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None);
let text = lines_to_text(&output.lines).join("\n");
assert!(
text.contains('▼'),
"info extras should still draw a diagram:\n{text}"
);
}
/// Raw mode shows the mermaid source instead of the diagram.
#[test]
fn test_mermaid_block_raw_mode_shows_source() {
let md = "```mermaid\ngraph TD\n A[Start] --> B[End]\n```\n";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, false, None);
let text = lines_to_text(&output.lines).join("\n");
assert!(text.contains("graph TD"), "raw should show source:\n{text}");
assert!(
!text.contains('▼'),
"raw should not draw a diagram:\n{text}"
);
}
/// Pretty mode must remove the opening `[` from `[text](url)` links.
/// Regression test: apply_transforms treated replace-with-empty-string
/// as "no transform applied" because it checked `result.is_empty()`.
#[test]
fn test_pretty_link_bracket_removed() {
let text = "Here is a [link](https://example.com) in text.\n\n";
let (output, _) = render_markdown_ratatui_full(text, test_style::STYLE, true, None);
let lines = lines_to_text(&output.lines);
assert!(
!lines[0].contains("[link"),
"Pretty mode should remove '[' from link. Got: {:?}",
lines[0]
);
assert!(
lines[0].contains("link (https://example.com)"),
"Pretty mode should render 'link (url)'. Got: {:?}",
lines[0]
);
}
/// Same regression for images: `![img](src)` should not show `[img`.
#[test]
fn test_pretty_image_bracket_removed() {
let text = "An ![image](src.png) here.\n\n";
let (output, _) = render_markdown_ratatui_full(text, test_style::STYLE, true, None);
let lines = lines_to_text(&output.lines);
let img_line = &lines[0];
assert!(
!img_line.contains("[image"),
"Pretty mode should remove '[' from image. Got: {:?}",
img_line
);
}
/// Regression: `count_newlines_in_range` panics when a checkpoint byte
/// offset from a thematic break falls inside a multi-byte character in
/// subsequent content (e.g., a 4-byte emoji like 📐).
///
/// Minimal repro: thematic break `---` followed by heading with emoji.
/// The checkpoint creates a byte offset that lands mid-emoji when used
/// to slice `self.text` in `text[from..to]`.
/// Nested blockquote with paragraph break and list inside inner quote.
#[test]
fn test_nested_blockquote_with_list() {
let md = "> Foo\n>\n> > Bar\n> >\n> > - Baz\n";
let (raw_output, _) = render_markdown_ratatui_full(md, test_style::STYLE, false, None);
assert_eq!(
lines_to_text(&raw_output.lines),
vec!["> Foo", ">", "> > Bar", "> >", "> > - Baz"],
"raw mode",
);
let (pretty_output, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None);
assert_eq!(
lines_to_text(&pretty_output.lines),
vec!["│ Foo", "", "│ │ Bar", "│ │", "│ │ • Baz"],
"pretty mode",
);
}
#[test]
fn test_emoji_after_thematic_break_does_not_panic() {
// "---\n\n## 📐 H\n\n" — 📐 is at bytes 8..12, checkpoint offset
// lands at byte 10 (inside the emoji), causing a panic in
// count_newlines_in_range which does text[from..to].
let md = "---\n\n## 📐 H\n\n";
let (_output, _cp) = render_markdown_ratatui_full(md, test_style::STYLE, true, None);
}
#[test]
fn test_list_followed_by_code_block_has_separator() {
// A list item followed by a code block should have a blank line between them
let md = "1. Hello\n```python\nworld\n```\n";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None);
let text = lines_to_text(&output.lines);
eprintln!("Lines: {text:#?}");
// Find the list item line and the code block line
let hello_idx = text.iter().position(|l| l.contains("Hello")).unwrap();
let world_idx = text.iter().position(|l| l.contains("world")).unwrap();
// There should be at least one blank line between them
assert!(
world_idx - hello_idx >= 2,
"Expected blank line between list item and code block. \
hello at {hello_idx}, world at {world_idx}. Lines: {text:#?}"
);
}
#[test]
fn test_code_block_empty_line_has_bg() {
use ratatui::style::Color;
// Create a style with a visible code_background
let mut style = test_style::STYLE;
style.code_background = anstyle::Style::new()
.bg_color(Some(anstyle::Color::Rgb(anstyle::RgbColor(30, 30, 46))));
let md = "```\nline1\n\nline3\n```\n";
let (output, _) = render_markdown_ratatui_full(md, style, true, None);
let expected_bg = Color::Rgb(30, 30, 46);
// All lines inside the code block should have the bg set
for (i, line) in output.lines.iter().enumerate() {
assert_eq!(
line.style.bg,
Some(expected_bg),
"Line {i} ({:?}) should have code_background, got {:?}",
lines_to_text(std::slice::from_ref(line))[0],
line.style.bg,
);
}
}
/// Regression: an unterminated bare fence with no trailing newline (the tail of a
/// streamed message) must keep code_background on its final line.
#[test]
fn test_unterminated_untagged_fence_final_line_has_bg() {
use ratatui::style::Color;
let mut style = test_style::STYLE;
style.code_background = anstyle::Style::new()
.bg_color(Some(anstyle::Color::Rgb(anstyle::RgbColor(30, 30, 46))));
let md = "```\nline1\n\nfinal line";
let (output, _) = render_markdown_ratatui_full(md, style, true, None);
let texts = lines_to_text(&output.lines);
assert!(
texts.last().is_some_and(|l| l.contains("final line")),
"expected the newline-less final line in output: {texts:#?}"
);
let expected_bg = Color::Rgb(30, 30, 46);
for (i, line) in output.lines.iter().enumerate() {
assert_eq!(
line.style.bg,
Some(expected_bg),
"Line {i} ({:?}) should have code_background",
texts[i],
);
}
}
/// Tables wider than max_table_width should be constrained to fit.
#[test]
fn test_table_constrained_to_max_width() {
use unicode_width::UnicodeWidthStr;
let md = "| Column A | Column B | Column C |\n|----------|----------|----------|\n| value 1 | value 2 | value 3 |\n\n";
// Render without constraint — table uses natural widths
let (output_full, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None);
let full_lines = lines_to_text(&output_full.lines);
let full_max_width = full_lines.iter().map(|l| l.width()).max().unwrap_or(0);
// Render with narrow constraint
let narrow = 30;
assert!(
full_max_width > narrow,
"Table should be wider than {narrow} naturally"
);
let mut buffers = crate::MarkdownBuffers::new();
let (output_narrow, _) = crate::render_markdown_ratatui_with_buffers_width(
md,
test_style::STYLE,
true,
&mut buffers,
None,
Some(narrow),
);
let narrow_lines = lines_to_text(&output_narrow.lines);
let narrow_max_width = narrow_lines.iter().map(|l| l.width()).max().unwrap_or(0);
assert!(
narrow_max_width <= narrow,
"Constrained table should fit within {narrow} columns, got {narrow_max_width}. Lines: {narrow_lines:#?}"
);
// All table lines should still have consistent widths
let table_widths: Vec<usize> = narrow_lines.iter().map(|l| l.width()).collect();
let first_width = table_widths[0];
for (i, &w) in table_widths.iter().enumerate() {
assert_eq!(
w, first_width,
"Table line {i} has width {w}, expected {first_width}"
);
}
}
/// When columns are shrunk, long cell content should be wrapped within the cell.
#[test]
fn test_table_cell_wrapping() {
let md = "| Very Long Column Name |\n|-----------------------|\n| Short |\n\n";
let mut buffers = crate::MarkdownBuffers::new();
let (output, _) = crate::render_markdown_ratatui_with_buffers_width(
md,
test_style::STYLE,
true,
&mut buffers,
None,
Some(15),
);
let text = lines_to_text(&output.lines);
eprintln!("Wrapped table: {text:#?}");
// The header "Very Long Column Name" should be wrapped across multiple lines
// since it doesn't fit in the constrained column width.
// All content should still be present (no truncation).
let all_text: String = text.join("");
assert!(
all_text.contains("Very") && all_text.contains("Long") && all_text.contains("Name"),
"All header words should be present (wrapped, not truncated). Got: {text:#?}"
);
}
/// Cell wrapping should break at punctuation/symbols, not mid-word.
/// Punct chars attach to whichever side gives a smaller max segment.
#[test]
fn test_table_cell_wraps_at_punctuation() {
use crate::parse::cell_word_separator;
fn words(s: &str) -> Vec<String> {
cell_word_separator(s)
.map(|w| format!("[{}|{}]", w.word, w.whitespace))
.collect()
}
// Equal-length sides: tie goes to attach-left
assert_eq!(words("foo/bar"), vec!["[foo/|]", "[bar|]"]);
// Break at space after comma (whitespace break, no attachment choice)
assert_eq!(words("hello, world"), vec!["[hello,| ]", "[world|]"]);
// Plain words only break on spaces
assert_eq!(words("hello world"), vec!["[hello| ]", "[world|]"]);
// Single-char segments separated by hyphens
assert_eq!(words("a-b-c"), vec!["[a-|]", "[b-|]", "[c|]"]);
// Unequal sides: punct attaches to shorter side to minimize max
// ABCD-EFG: left gives max(5,3)=5, right gives max(4,4)=4 → right
assert_eq!(words("ABCD-EFG"), vec!["[ABCD|]", "[-EFG|]"]);
// Comma and dot between digits stay together (number formatting)
assert_eq!(words("$145,000"), vec!["[$145,000|]"]);
assert_eq!(words("3.14"), vec!["[3.14|]"]);
assert_eq!(words("1.0.2"), vec!["[1.0.2|]"]);
// Hyphens between digits are breakable (phones, dates, IDs)
// Attachment is chosen to minimize max segment width.
// 2019-03-15: right gives max(4,3,3)=4 < left max(5,3,2)=5
assert_eq!(words("2019-03-15"), vec!["[2019|]", "[-03|]", "[-15|]"]);
// 555-0101: right gives max(3,5)=5 vs left max(4,4)=4 → left
assert_eq!(words("555-0101"), vec!["[555-|]", "[0101|]"]);
// Verify a full phone number breaks correctly
let phone = words("+44-20-7555-0118");
// All segments should be present, phone is breakable
assert!(phone.len() > 1, "phone number should be breakable");
assert_eq!(
words("(415) 555-0101"),
vec!["[(415)| ]", "[555-|]", "[0101|]"]
);
// EMP-1001: no digit before `-`, and `1` after is not alphabetic →
// stays together (it's an ID, not digit-punct-digit)
assert_eq!(words("EMP-1001"), vec!["[EMP-1001|]"]);
}
/// URLs should be treated as unbreakable words so that terminal
/// Cmd+Click detection works when table cells wrap.
#[test]
fn test_table_cell_url_not_broken() {
use crate::parse::cell_word_separator;
fn words(s: &str) -> Vec<String> {
cell_word_separator(s)
.map(|w| format!("[{}|{}]", w.word, w.whitespace))
.collect()
}
// A URL should be a single unbreakable word
assert_eq!(
words("https://example.com/path/to/page"),
vec!["[https://example.com/path/to/page|]"]
);
// URL with text before and after breaks at spaces, URL stays intact
assert_eq!(
words("see https://example.com/foo for details"),
vec![
"[see| ]",
"[https://example.com/foo| ]",
"[for| ]",
"[details|]"
]
);
// http:// URLs are also preserved
assert_eq!(
words("http://example.com/a-b/c"),
vec!["[http://example.com/a-b/c|]"]
);
// Multiple URLs in the same cell
assert_eq!(
words("https://a.com/x https://b.com/y"),
vec!["[https://a.com/x| ]", "[https://b.com/y|]"]
);
// URL with query params and fragments
assert_eq!(
words("https://example.com/search?q=hello&lang=en#results"),
vec!["[https://example.com/search?q=hello&lang=en#results|]"]
);
// Non-http schemes (ftp, ssh, etc.) are also preserved
assert_eq!(
words("ftp://files.example.com/pub/data"),
vec!["[ftp://files.example.com/pub/data|]"]
);
assert_eq!(
words("ssh://git@github.com/org/repo"),
vec!["[ssh://git@github.com/org/repo|]"]
);
}
/// Inline formatting (bold, italic, code) should be preserved per-span
/// when table cells are wrapped across multiple visual lines.
#[test]
fn test_table_preserves_inline_formatting() {
// Table with inline code in a cell
let md = "| A | B |\n|---|---|\n| 1 | hello world `abc` |\n\n";
let mut buffers = crate::MarkdownBuffers::new();
let (output, _) = crate::render_markdown_ratatui_with_buffers_width(
md,
test_style::STYLE,
true,
&mut buffers,
None,
Some(30), // narrow enough to force wrapping in column B
);
// Find the lines that contain "abc" — they should have a styled span
// with the code style, not just plain text.
let mut found_code_span = false;
for line in &output.lines {
for span in &line.spans {
if span.content.contains("abc") {
// Inline code should have some style applied (not default)
let default_style = ratatui::style::Style::default();
assert_ne!(
span.style, default_style,
"Inline code `abc` should have code formatting, got default style"
);
found_code_span = true;
}
}
}
assert!(
found_code_span,
"Should find a span containing 'abc' with code formatting"
);
}
/// Regression: table cells containing multi-byte UTF-8 characters (em-dash '—',
/// CJK, emoji, etc.) could panic with "byte index N is not a char boundary"
/// when cell wrapping causes `prev_len` (sum of wrapped-line byte lengths) to
/// land inside a multi-byte character sequence.
#[test]
fn test_table_cell_with_multibyte_chars_does_not_panic() {
// Em-dash '—' is 3 bytes (0xE2 0x80 0x94). Force wrapping so the
// prev_len calculation for the second visual line can land mid-char.
let md = "| A |\n|---|\n| hello world — goodbye world |\n\n";
let mut buffers = crate::MarkdownBuffers::new();
let (output, _) = crate::render_markdown_ratatui_with_buffers_width(
md,
test_style::STYLE,
true,
&mut buffers,
None,
Some(20), // narrow enough to force wrapping around the em-dash
);
let text = lines_to_text(&output.lines);
let all_text: String = text.join("");
// All content should still be present (no truncation or crash).
assert!(
all_text.contains("hello") && all_text.contains("goodbye"),
"All cell words should be present after wrapping. Got: {text:#?}"
);
}
/// Same regression for CJK and emoji characters in table cells.
#[test]
fn test_table_cell_with_cjk_and_emoji_does_not_panic() {
// Mix CJK (3 bytes each), emoji (4 bytes), and ASCII to stress char boundaries.
let md = "| Col |\n|-----|\n| \u{4F60}\u{597D}\u{4E16}\u{754C} hello \u{1F680}\u{1F30D} world |\n\n";
let mut buffers = crate::MarkdownBuffers::new();
let (output, _) = crate::render_markdown_ratatui_with_buffers_width(
md,
test_style::STYLE,
true,
&mut buffers,
None,
Some(15),
);
let text = lines_to_text(&output.lines);
let all_text: String = text.join("");
assert!(
all_text.contains("hello") && all_text.contains("world"),
"ASCII words should survive wrapping with CJK/emoji. Got: {text:#?}"
);
}
/// Table source map: rendered line numbers must not exceed the table's
/// actual source line count, and must map to the correct source lines.
#[test]
fn test_table_source_map_stays_within_bounds() {
// 4 source lines: header (0), separator (1), row1 (2), row2 (3)
let md = "| A | B |\n|---|---|\n| x | y |\n| w | z |\n\n";
let table_start_line = 0usize;
let table_source_lines = 4usize; // header + separator + 2 rows
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None);
for (i, &src_line) in output.line_source_map.iter().enumerate() {
assert!(
src_line < table_start_line + table_source_lines,
"Rendered line {i} maps to source line {src_line}, \
but table only has {table_source_lines} source lines \
(0..{}). Source map: {:?}",
table_start_line + table_source_lines,
output.line_source_map,
);
}
}
/// Table source map: header, separator, and body rows map to correct offsets.
#[test]
fn test_table_source_map_correct_offsets() {
let md = "| H1 | H2 |\n|----|----|\n| r1 | r2 |\n| r3 | r4 |\n\n";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None);
let text = lines_to_text(&output.lines);
let map = &output.line_source_map;
// Find which rendered lines contain table content.
// Source offsets: header=0, separator=1, row1=2, row2=3
for (i, line_text) in text.iter().enumerate() {
let src = map[i];
if line_text.contains("H1") || line_text.contains("H2") {
assert_eq!(
src, 0,
"Header content line {i} should map to source 0, got {src}"
);
}
if line_text.contains("r1") || line_text.contains("r2") {
assert_eq!(
src, 2,
"Row 1 content line {i} should map to source 2, got {src}"
);
}
if line_text.contains("r3") || line_text.contains("r4") {
assert_eq!(
src, 3,
"Row 2 content line {i} should map to source 3, got {src}"
);
}
}
}
/// Table source map with cell wrapping: wrapped continuation lines must
/// map to the same source line as the first visual line of that row.
#[test]
fn test_table_source_map_with_cell_wrapping() {
let md = "| Name | Description |\n|------|-------------|\n| short | A very long description that will wrap |\n\n";
let mut buffers = crate::MarkdownBuffers::new();
let (output, _) = crate::render_markdown_ratatui_with_buffers_width(
md,
test_style::STYLE,
true,
&mut buffers,
None,
Some(30),
);
let table_source_lines = 3; // header + separator + 1 row
for (i, &src_line) in output.line_source_map.iter().enumerate() {
assert!(
src_line < table_source_lines,
"Wrapped table line {i} maps to source {src_line}, \
exceeds table source lines ({table_source_lines}). Map: {:?}",
output.line_source_map,
);
}
}
/// Fenced block with `lineStart:lineEnd:path` (citation-style) uses the file
/// extension for syntect, same as a ` ```rust` block.
#[test]
fn test_citation_code_fence_highlights_as_rust() {
let syntect = crate::syntax::test_syntect();
let code = "const DEFAULT_READ_LIMIT: usize = 2000;\n";
let md_cite = format!("```37:65:crates/x/read.rs\n{code}```\n\n");
let md_rust = format!("```rust\n{code}```\n\n");
let (out_cite, _) =
render_markdown_ratatui_full(&md_cite, test_style::STYLE, true, Some(syntect));
let (out_rust, _) =
render_markdown_ratatui_full(&md_rust, test_style::STYLE, true, Some(syntect));
fn const_line_span_count(out: &crate::MarkdownRenderOutput) -> usize {
out.lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.as_ref().contains("const")))
.expect("line with 'const' should exist")
.spans
.len()
}
let s_cite = const_line_span_count(&out_cite);
let s_rust = const_line_span_count(&out_rust);
assert_eq!(
s_cite, s_rust,
"citation fence should match ```rust highlight shape"
);
assert!(
s_cite > 1,
"const line should have multiple styled spans, got {s_cite}"
);
}
/// InlineHtml (e.g. `<PathBuf>`) inside a table cell must not leak raw
/// text below the rendered table. Regression for the Replace-inside-table bug.
#[test]
fn test_table_inline_html_no_raw_text_leak_ratatui() {
let md = "| Col A | Col B |\n|-------|-------|\n| Arc<PathBuf> | optimization |\n| normal | row |\n\n";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None);
let text = lines_to_text(&output.lines);
let joined = text.join("\n");
// The raw markdown pipe syntax must not appear in rendered output
assert!(
!joined.contains("| normal"),
"Raw markdown table syntax leaked below rendered table. Lines: {text:#?}"
);
assert!(
!joined.contains("| optimization"),
"Raw table cell content leaked as plain text. Lines: {text:#?}"
);
}
/// ANSI render path: same regression — InlineHtml Replace must not
/// corrupt `last_pos` and re-emit table content as raw text.
#[test]
fn test_table_inline_html_no_raw_text_leak_ansi() {
let md = "| Col A | Col B |\n|-------|-------|\n| Arc<PathBuf> | optimization |\n| normal | row |\n\n";
let (output, _) = crate::render_markdown(md, test_style::STYLE, true, None);
assert!(
!output.contains("| normal"),
"Raw markdown table syntax leaked in ANSI output. Got: {output}"
);
assert!(
!output.contains("| optimization"),
"Raw table cell content leaked in ANSI output. Got: {output}"
);
}
/// InlineHtml content must be captured into table cells so it appears
/// in the formatted table, not silently dropped.
#[test]
fn test_table_inline_html_captured_in_cell() {
let md = "| Type |\n|------|\n| Arc<PathBuf> |\n\n";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None);
let text = lines_to_text(&output.lines);
let all_text: String = text.join("");
assert!(
all_text.contains("<PathBuf>"),
"InlineHtml content should appear in formatted table cell. Got: {text:#?}"
);
assert!(
all_text.contains("Arc"),
"Text before InlineHtml should appear in cell. Got: {text:#?}"
);
}
/// Multiple HTML-like tags across different cells and rows must all
/// render correctly without leaking.
#[test]
fn test_table_multiple_inline_html_tags() {
let md = "| Input | Output |\n|-------|--------|\n| Vec<String> | Option<i32> |\n| Box<dyn Trait> | Result<T> |\n\n";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None);
let text = lines_to_text(&output.lines);
let joined = text.join("\n");
// No raw pipe-delimited rows should leak
assert!(
!joined.contains("| Vec"),
"Raw table syntax leaked with multiple HTML tags. Lines: {text:#?}"
);
assert!(
!joined.contains("| Box"),
"Raw table syntax leaked with multiple HTML tags. Lines: {text:#?}"
);
// Cell content should be present in the table
let all_text: String = text.join("");
assert!(
all_text.contains("Vec"),
"Vec should appear in table. Got: {text:#?}"
);
assert!(
all_text.contains("Box"),
"Box should appear in table. Got: {text:#?}"
);
}
/// ANSI render path: multiple HTML-like tags across cells and rows
/// must not leak raw text via the `current` accumulator merge logic.
#[test]
fn test_table_multiple_inline_html_tags_ansi() {
let md = "| Input | Output |\n|-------|--------|\n| Vec<String> | Option<i32> |\n| Box<dyn Trait> | Result<T> |\n\n";
let (output, _) = crate::render_markdown(md, test_style::STYLE, true, None);
assert!(
!output.contains("| Vec"),
"Raw table syntax leaked in ANSI multi-tag output. Got: {output}"
);
assert!(
!output.contains("| Box"),
"Raw table syntax leaked in ANSI multi-tag output. Got: {output}"
);
}
/// Leading content before a table exercises the `push` flush at the
/// Table Start event followed by the `current.0` reset after rendering.
#[test]
fn test_table_with_leading_text_ansi() {
let md = "Hello world\n\n| Col |\n|-----|\n| Arc<PathBuf> |\n\n";
let (output, _) = crate::render_markdown(md, test_style::STYLE, true, None);
assert!(
output.contains("Hello world"),
"Leading text should be present. Got: {output}"
);
assert!(
!output.contains("| Col"),
"Raw table syntax leaked after leading text in ANSI output. Got: {output}"
);
}
#[test]
fn test_table_br_tag_becomes_line_break() {
let md = "| Col |\n|-----|\n| hello<br>world |\n\n";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None);
let text = lines_to_text(&output.lines);
let joined = text.join("\n");
assert!(!joined.contains("<br>"), "literal <br> leaked: {joined}");
assert!(
joined.contains("hello") && joined.contains("world"),
"cell content missing: {joined}"
);
assert!(
!text
.iter()
.any(|l| l.contains("hello") && l.contains("world")),
"hello and world must be on separate visual lines: {joined}"
);
}
#[test]
fn test_table_br_tag_variants() {
let md = "| Col |\n|-----|\n| a<BR>b<br/>c<br />d |\n\n";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None);
let text = lines_to_text(&output.lines);
let joined = text.join("\n");
for tag in ["<BR>", "<br/>", "<br />"] {
assert!(!joined.contains(tag), "literal {tag} leaked: {joined}");
}
for ch in ['a', 'b', 'c', 'd'] {
assert!(
text.iter().any(|l| l.contains(ch)),
"segment '{ch}' missing: {joined}"
);
}
}
#[test]
fn test_table_br_tag_ansi() {
let md = "| Col |\n|-----|\n| hello<br>world |\n\n";
let (output, _) = crate::render_markdown(md, test_style::STYLE, true, None);
assert!(
!output.contains("<br>"),
"literal <br> in ANSI output: {output}"
);
}
#[test]
fn test_br_tag_outside_table() {
let md = "hello<br>world\n\n";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None);
let text = lines_to_text(&output.lines);
let joined = text.join("\n");
assert!(
!joined.contains("<br>"),
"literal <br> outside table: {joined}"
);
}
// CommonMark soft breaks collapse to a single space inside a plain
// paragraph; hard breaks and block-container continuations (list
// items, blockquotes) still split into separate visual lines.
#[test]
fn test_soft_break_plain_paragraph_collapses_to_space() {
let md = "Foo bar\nbaz qux.";
for pretty in [false, true] {
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, pretty, None);
let text = lines_to_text(&output.lines);
assert_eq!(text, vec!["Foo bar baz qux."], "pretty={pretty}: {text:?}");
}
}
#[test]
fn test_soft_break_original_bug_repro() {
let md = "- Tiny emit guard in pretty.rs: empty-reflowed KDoc output with no \"<decl>\n\" pollution).";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None);
let text = lines_to_text(&output.lines);
assert_eq!(text.len(), 1, "got: {text:?}");
assert!(
text[0].contains("no \"<decl> \" pollution)."),
"got: {text:?}"
);
}
#[test]
fn test_soft_break_multiple_consecutive() {
let md = "alpha\nbeta\ngamma";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, false, None);
let text = lines_to_text(&output.lines);
assert_eq!(text, vec!["alpha beta gamma"], "got: {text:?}");
}
#[test]
fn test_soft_break_around_inline_html_decl_tag() {
// <decl> arrives as Event::InlineHtml; the following `\n` is the soft break.
let md = "Foo bar <decl>\nbaz qux.";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None);
let text = lines_to_text(&output.lines);
assert_eq!(text.len(), 1, "got: {text:?}");
assert!(text[0].contains("<decl> baz qux."), "got: {text:?}");
}
#[test]
fn test_soft_break_ansi_render_path_no_mid_sentence_newline() {
// render_ansi has its own `split('\n')` loop; verify the parser fix reaches it.
let md = "Foo bar\nbaz qux.";
let (output, _) = crate::render_markdown(md, test_style::STYLE, false, None);
let body = output.trim_end_matches('\n');
assert!(!body.contains('\n'), "{output:?}");
assert!(body.contains("Foo bar baz qux."), "{output:?}");
}
#[test]
fn test_hard_break_two_trailing_spaces_still_breaks() {
let md = "Foo bar \nbaz qux.";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, false, None);
let text = lines_to_text(&output.lines);
assert_eq!(text.len(), 2, "got: {text:?}");
assert_eq!(text[0].trim_end(), "Foo bar");
assert_eq!(text[1], "baz qux.");
}
#[test]
fn test_hard_break_backslash_still_breaks() {
let md = "Foo bar\\\nbaz qux.";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, false, None);
let text = lines_to_text(&output.lines);
assert_eq!(text.len(), 2, "got: {text:?}");
assert!(text[0].starts_with("Foo bar"), "got: {text:?}");
assert_eq!(text[1], "baz qux.");
}
#[test]
fn test_inline_br_tag_still_breaks() {
let md = "Foo bar<br>baz qux.";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None);
let text = lines_to_text(&output.lines);
let joined = text.join("\n");
assert!(!joined.contains("<br>"), "{joined:?}");
assert!(text.len() >= 2, "{text:?}");
}
#[test]
fn test_code_block_internal_newlines_still_break() {
let md = "```rust\nfn foo() {}\nfn bar() {}\n```\n";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, false, None);
let text = lines_to_text(&output.lines);
let foo_idx = text.iter().position(|l| l.contains("fn foo() {}"));
let bar_idx = text.iter().position(|l| l.contains("fn bar() {}"));
assert!(
foo_idx.is_some() && bar_idx.is_some() && foo_idx != bar_idx,
"got: {text:?}",
);
}
#[test]
fn test_inline_code_with_real_newline_still_splits() {
// Inline code's `\n` is part of the Event::Code source slice; no
// SoftBreak fires, so the fix must not over-collapse it.
let md = "foo `bar\nbaz` qux";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, false, None);
let text = lines_to_text(&output.lines);
assert!(text.len() >= 2, "got: {text:?}");
let bar_idx = text.iter().position(|l| l.contains("bar"));
let baz_idx = text.iter().position(|l| l.contains("baz"));
assert!(
bar_idx.is_some() && baz_idx.is_some() && bar_idx != baz_idx,
"got: {text:?}",
);
}
#[test]
fn test_soft_break_in_bullet_list_item_preserves_lines() {
// Lazy continuation inside a list item is a soft break, but the
// continuation indent belongs to a new visual line; collapsing
// would leave stray indent whitespace mid-line.
let md = "- first line\n second line\n";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None);
let text = lines_to_text(&output.lines);
assert_eq!(text.len(), 2, "got: {text:?}");
assert!(text[0].contains("first line"), "got: {text:?}");
assert!(text[1].contains("second line"), "got: {text:?}");
}
#[test]
fn test_soft_break_in_blockquote_preserves_lines() {
// Continuation `>` markers belong to new visual lines; collapsing
// would leak a stray `│` (pretty) or `>` (raw) mid-paragraph.
let md = "> first line\n> second line\n";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None);
let text = lines_to_text(&output.lines);
assert_eq!(text.len(), 2, "got: {text:?}");
assert!(text[0].contains("first line"), "got: {text:?}");
assert!(text[1].contains("second line"), "got: {text:?}");
assert!(
!text[0].contains("second line") && !text[1].contains("first line"),
"lines must not collapse: {text:?}",
);
}
#[test]
fn test_soft_break_crlf_range_preserves_length() {
// pulldown emits SoftBreak with a 2-byte range for CRLF; the
// transform must replace both bytes to keep the byte-length
// invariant force transforms rely on in render_ansi.
let md = "Foo bar\r\nbaz qux.";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, false, None);
let text = lines_to_text(&output.lines);
assert_eq!(text, vec!["Foo bar baz qux."], "got: {text:?}");
let (ansi, _) = crate::render_markdown(md, test_style::STYLE, false, None);
assert!(!ansi.trim_end_matches('\n').contains('\n'), "{ansi:?}");
assert!(ansi.contains("Foo bar baz qux."), "{ansi:?}");
}
#[test]
fn test_source_map_preserved_for_soft_break_collapse() {
let md = "Foo bar\nbaz qux.";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, false, None);
assert_eq!(output.lines.len(), 1);
assert_eq!(output.line_source_map.len(), 1);
assert!(
output.line_source_map[0] <= 1,
"got {}",
output.line_source_map[0]
);
}
#[test]
fn test_source_map_preserved_for_hard_break() {
let md = "Foo bar \nbaz qux.";
let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, false, None);
assert_eq!(output.lines.len(), 2, "lines: {:?}", output.lines);
assert_eq!(output.line_source_map, vec![0, 1]);
}
// Soft-break inside a markdown link is covered by
// `hyperlinks::hyperlink_tests::soft_break_inside_link_text_preserves_column_range`.
/// An indented fenced code block (common when an LLM nests code under a
/// list, or simply indents the fence) must render the same as a
/// non-indented one: pulldown-cmark strips the indentation from the
/// content, and the renderer must hide the indentation on the opening
/// fence line too. Regression test for the bug where the first content
/// line kept its leading indentation and a spurious blank line was
/// appended.
#[test]
fn test_indented_fenced_code_block_strips_indentation() {
let syn = crate::syntax::test_syntect();
let indented = " ```cpp\n cellContChargeLimits_S cellContChargeLimits;\n cellChargeTables_S cellChargeTables;\n ```\n";
let (output, _) =
render_markdown_ratatui_full(indented, test_style::STYLE, true, Some(syn));
let text = lines_to_text(&output.lines);
assert_eq!(
text,
vec![
"cellContChargeLimits_S cellContChargeLimits;",
"cellChargeTables_S cellChargeTables;",
],
"indented code block should render dedented with no spurious blank line: {text:#?}",
);
}
/// Indented and non-indented code blocks must produce identical pretty
/// output (the indentation is purely structural).
#[test]
fn test_indented_code_block_matches_non_indented() {
let syn = crate::syntax::test_syntect();
let non_indented = "```rust\nfn main() {\n let x = 1;\n}\n```\n";
let indented = " ```rust\n fn main() {\n let x = 1;\n }\n ```\n";
let (out_plain, _) =
render_markdown_ratatui_full(non_indented, test_style::STYLE, true, Some(syn));
let (out_indent, _) =
render_markdown_ratatui_full(indented, test_style::STYLE, true, Some(syn));
assert_eq!(
lines_to_text(&out_plain.lines),
lines_to_text(&out_indent.lines),
"indented fenced code block should match non-indented output",
);
}
/// A fenced code block nested inside a list item renders dedented, with a
/// single blank separator before the code and no leading indentation
/// leaking onto the first code line.
#[test]
fn test_code_block_in_list_strips_indentation() {
let syn = crate::syntax::test_syntect();
let in_list = "1. Do this:\n ```cpp\n int x = 1;\n int y = 2;\n ```\n";
let (output, _) = render_markdown_ratatui_full(in_list, test_style::STYLE, true, Some(syn));
let text = lines_to_text(&output.lines);
// No rendered line should begin with leftover indentation.
let x_idx = text.iter().position(|l| l.contains("int x = 1;")).unwrap();
let y_idx = text.iter().position(|l| l.contains("int y = 2;")).unwrap();
assert_eq!(text[x_idx], "int x = 1;", "first code line: {text:#?}");
assert_eq!(text[y_idx], "int y = 2;", "second code line: {text:#?}");
assert!(
text.last().is_some_and(|l| !l.is_empty()),
"no spurious trailing blank line: {text:#?}",
);
}
}
/// Integration tests for LaTeX math rendering across all four delimiter
/// forms (`$...$`, `$$...$$`, `\(...\)`, `\[...\]`).
#[cfg(test)]
mod math_tests {
use crate::style::test_style;
use crate::{render_markdown, render_markdown_ratatui_full};
fn lines_to_text(lines: &[ratatui::text::Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
}
fn pretty_lines(text: &str) -> Vec<String> {
let (output, _) = render_markdown_ratatui_full(text, test_style::STYLE, true, None);
lines_to_text(&output.lines)
}
#[test]
fn dollar_inline_math_renders_unicode() {
let lines = pretty_lines("Energy is $E = mc^2$ here.\n\n");
assert_eq!(lines[0], "Energy is E = mc² here.", "got: {lines:#?}");
}
#[test]
fn dollar_inline_math_hides_delimiters_in_pretty_mode() {
let lines = pretty_lines("So $x_1 + x_2$ holds.\n\n");
assert!(!lines[0].contains('$'), "got: {lines:#?}");
assert!(lines[0].contains("x₁ + x₂"), "got: {lines:#?}");
}
#[test]
fn raw_mode_preserves_inline_math_source() {
let text = "Energy is $E = mc^2$ here.\n\n";
let (output, _) = render_markdown_ratatui_full(text, test_style::STYLE, false, None);
let lines = lines_to_text(&output.lines);
assert!(lines[0].contains("$E = mc^2$"), "got: {lines:#?}");
}
#[test]
fn paren_inline_math_renders_unicode() {
let lines = pretty_lines("Sum \\(\\alpha + \\beta\\) end.\n\n");
assert_eq!(lines[0], "Sum α + β end.", "got: {lines:#?}");
}
#[test]
fn padded_paren_inline_math_renders_unicode() {
// Regression: whitespace just inside `\( … \)` made the normalized
// `$ … $` violate pulldown's dollar-math flanking rule, so it used to
// render as raw `$ … $`. The normalizer now trims that padding.
let lines = pretty_lines("Sum \\( x+y \\) end.\n\n");
assert_eq!(lines[0], "Sum x+y end.", "got: {lines:#?}");
assert!(
!lines[0].contains('$'),
"delimiters must be gone: {lines:#?}"
);
}
#[test]
fn padded_paren_inline_math_with_braces_renders() {
let lines = pretty_lines("Set \\( S = \\{ x : x > 0 \\} \\) defined.\n\n");
let joined = lines.join("\n");
assert!(joined.contains("x : x > 0"), "got: {lines:#?}");
assert!(!joined.contains('$'), "no raw dollar math: {lines:#?}");
}
#[test]
fn paren_inline_math_in_list_item() {
let lines = pretty_lines("- implies \\(p \\to q\\)\n- plain\n\n");
assert!(lines[0].contains("implies p → q"), "got: {lines:#?}");
}
#[test]
fn paren_inline_math_in_heading() {
let lines = pretty_lines("## About \\(\\pi^2\\)\n\n");
assert!(lines[0].contains("About π²"), "got: {lines:#?}");
}
#[test]
fn dollar_inline_math_in_heading() {
let lines = pretty_lines("# Energy $E=mc^2$\n\n");
assert!(lines[0].contains("Energy E=mc²"), "got: {lines:#?}");
}
#[test]
fn bracket_display_math_in_heading() {
// pulldown-cmark keeps heading content inside a `Heading` block (no
// wrapping paragraph), so the `\[...\]` source scan must also run on
// heading end. `$$...$$` in the same position already converts via
// `Event::DisplayMath`.
let lines = pretty_lines("## Identity \\[x^2 + y^2 = z^2\\]\n\nAfter.\n\n");
let joined = lines.join("\n");
assert!(joined.contains("x² + y² = z²"), "got: {lines:#?}");
assert!(!joined.contains("\\["), "got: {lines:#?}");
}
#[test]
fn escaped_backslash_paren_is_not_math() {
// `\\(` is a literal backslash followed by a paren — not a math open.
let lines = pretty_lines("Literal \\\\(x\\\\) here.\n\n");
let joined = lines.join("\n");
// Pulldown renders the escapes; no Unicode conversion should occur
// and the parens must survive.
assert!(joined.contains("(x"), "got: {lines:#?}");
}
#[test]
fn emphasis_inside_paren_math_falls_back() {
// `*nope*` becomes emphasis, splitting the text events, so the span
// is not converted; content must still render.
let lines = pretty_lines("a \\(*nope*\\) b\n\n");
let joined = lines.join("\n");
assert!(joined.contains("nope"), "got: {lines:#?}");
assert!(!joined.contains('→'), "got: {lines:#?}");
}
#[test]
fn display_math_dollar_renders_block() {
let lines =
pretty_lines("Before.\n\n$$\n\\int_0^1 x \\, dx = \\frac{1}{2}\n$$\n\nAfter.\n\n");
let math_line = lines
.iter()
.find(|l| l.contains('∫'))
.expect("math block line");
assert_eq!(math_line.trim(), "∫₀¹ x dx = ½", "got: {lines:#?}");
// Block lines are indented.
assert!(math_line.starts_with(" "), "got: {lines:#?}");
}
#[test]
fn display_math_dollar_inline_form_renders_block() {
let lines = pretty_lines("text $$x^2 + y^2 = z^2$$ more\n\n");
let idx_text = lines.iter().position(|l| l.contains("text")).unwrap();
let idx_math = lines
.iter()
.position(|l| l.contains("x² + y² = z²"))
.unwrap();
let idx_more = lines.iter().position(|l| l.contains("more")).unwrap();
assert!(idx_text < idx_math, "text before math: {lines:#?}");
assert!(idx_math < idx_more, "math before trailing text: {lines:#?}");
}
#[test]
fn display_math_bracket_renders_block() {
let text = "The AM-GM inequality:\n\n\\[\n\\frac{a+b}{2} \\ge \\sqrt{ab}\n\\]\n\nDone.\n\n";
let lines = pretty_lines(text);
let math_line = lines
.iter()
.find(|l| l.contains('≥'))
.expect("math block line");
assert_eq!(math_line.trim(), "(a+b)/2 ≥ √(ab)", "got: {lines:#?}");
assert!(!lines.join("\n").contains("\\["), "got: {lines:#?}");
}
#[test]
fn display_math_bracket_single_line_renders_block() {
let lines = pretty_lines("\\[E = mc^2\\]\n\nAfter.\n\n");
let math_line = lines.iter().find(|l| l.contains("mc²")).expect("math line");
assert_eq!(math_line.trim(), "E = mc²", "got: {lines:#?}");
}
#[test]
fn display_math_bracket_in_raw_mode_shows_canonical_dollars() {
// The delimiter normalizer rewrites `\[…\]` → `$$…$$` before parsing, so
// raw mode shows the canonical `$$` form (the math→Unicode conversion is
// still a pretty-only overlay, so the TeX body itself is preserved).
let text = "\\[E = mc^2\\]\n\n";
let (output, _) = render_markdown_ratatui_full(text, test_style::STYLE, false, None);
let joined = lines_to_text(&output.lines).join("\n");
assert!(joined.contains("$$E = mc^2$$"), "got: {joined:?}");
assert!(!joined.contains("\\["), "got: {joined:?}");
}
#[test]
fn display_math_with_lone_equals_line_renders_block() {
// Symptom 1: a lone `=` line inside a display span is a
// CommonMark setext underline; unjoined, the first line became an H1
// and the math rendered as raw TeX.
let text = "The loss:\n\n\\[\n\\boxed{\n\\mathcal{L}_{\\text{MTP}}\n=\n\\sum_{i=0}^{2}\n\\gamma^{i}\\,\n\\mathbb{E}_{\\text{positions, mask}}\n\\Big[\n\\mathrm{KL}\\big(\n \\mathrm{softmax}(z_{\\text{torso}}^{(s_i)})\n \\;\\big\\|\\;\n \\mathrm{softmax}(z_{\\text{draft}}^{(i)})\n\\big)\n\\Big]\n}\n\\]\n\nAfter.\n\n";
let lines = pretty_lines(text);
let joined = lines.join("\n");
let math_line = lines
.iter()
.find(|l| l.contains(''))
.expect("math block line");
assert!(math_line.contains("_(MTP) = ∑ᵢ₌₀²"), "got: {lines:#?}");
assert!(joined.contains("softmax(z_(torso)"), "got: {lines:#?}");
assert!(!joined.contains('$'), "no raw delimiters: {lines:#?}");
assert!(!joined.contains("\\["), "got: {lines:#?}");
assert!(!joined.contains("boxed"), "got: {lines:#?}");
}
#[test]
fn dollar_display_math_with_lone_equals_line_renders_block() {
let lines = pretty_lines("$$\nx\n=\ny\n$$\n\nAfter.\n\n");
let math_line = lines
.iter()
.find(|l| l.contains("x = y"))
.expect("math block line");
assert!(math_line.starts_with(" "), "block indent: {lines:#?}");
assert!(!lines.join("\n").contains('$'), "got: {lines:#?}");
}
#[test]
fn text_subscript_in_table_cell_renders_readable() {
// Symptom 2: `p_{\text{torso}}` in a table cell became the
// modifier-letter run `pₜₒᵣₛₒ`, which renders with visible gaps in
// fonts lacking those glyphs.
let text = "| Who | Soft-teacher |\n|-----|--------------|\n| **Torso** | \\(p_{\\text{torso}}(\\cdot \\mid T_0,\\ldots,T_i)\\) |\n\n";
let lines = pretty_lines(text);
let joined = lines.join("\n");
assert!(joined.contains("p_(torso)(⋅ T₀,…,Tᵢ)"), "got: {lines:#?}");
assert!(!joined.contains('ₜ'), "no modifier-letter runs: {lines:#?}");
}
#[test]
fn aligned_environment_renders_multiple_lines() {
let text =
"\\[\n\\begin{aligned}\nf(x) &= x^2 \\\\\ng(x) &= 2x\n\\end{aligned}\n\\]\n\nEnd.\n\n";
let lines = pretty_lines(text);
let idx_f = lines.iter().position(|l| l.contains("f(x) = x²")).unwrap();
let idx_g = lines.iter().position(|l| l.contains("g(x) = 2x")).unwrap();
assert_eq!(idx_g, idx_f + 1, "consecutive block lines: {lines:#?}");
}
#[test]
fn cases_environment_renders_brace_column() {
let text = "$$\n|x| = \\begin{cases} x & x \\ge 0 \\\\ -x & x < 0 \\end{cases}\n$$\n\n";
let lines = pretty_lines(text);
let joined = lines.join("\n");
assert!(joined.contains('⎧'), "got: {lines:#?}");
assert!(joined.contains('⎩'), "got: {lines:#?}");
}
#[test]
fn inline_math_in_table_cell_renders_unicode() {
let text = "| Col | Math |\n|-----|------|\n| a | $x^2 + 1$ |\n\n";
let lines = pretty_lines(text);
let joined = lines.join("\n");
assert!(joined.contains("x² + 1"), "got: {lines:#?}");
assert!(!joined.contains('$'), "got: {lines:#?}");
}
#[test]
fn paren_inline_math_in_table_cell_renders_unicode() {
// `\(…\)` inside a table cell must convert. Previously the
// backslash-form scanner was disabled inside tables, leaving raw TeX.
// Normalization rewrites `\(…\)` → `$…$` before parsing, so the existing
// in-cell `$` path converts it.
let text = "| Mode | Metric |\n|------|--------|\n| Rate | \\(\\alpha + \\beta\\) |\n\n";
let lines = pretty_lines(text);
let joined = lines.join("\n");
assert!(joined.contains("α + β"), "got: {lines:#?}");
assert!(
!joined.contains("\\("),
"raw TeX must not survive: {lines:#?}"
);
assert!(!joined.contains('$'), "delimiters hidden: {lines:#?}");
}
#[test]
fn bracket_display_math_in_table_cell_renders_unicode() {
// `\[…\]` inside a cell renders single-line (no room for a block).
let text = "| Col | Math |\n|-----|------|\n| a | \\[x^2\\] |\n\n";
let lines = pretty_lines(text);
let joined = lines.join("\n");
assert!(joined.contains(""), "got: {lines:#?}");
assert!(!joined.contains("\\["), "got: {lines:#?}");
}
#[test]
fn paren_inline_math_in_blockquote_renders_unicode() {
let lines = pretty_lines("> energy \\(E = mc^2\\) noted\n\n");
let joined = lines.join("\n");
assert!(joined.contains("E = mc²"), "got: {lines:#?}");
assert!(!joined.contains("\\("), "got: {lines:#?}");
}
#[test]
fn equation_environment_converts_to_block() {
let text = "Before.\n\n\\begin{equation}\nE = mc^2\n\\end{equation}\n\nAfter.\n\n";
let lines = pretty_lines(text);
let joined = lines.join("\n");
assert!(joined.contains("E = mc²"), "got: {lines:#?}");
assert!(!joined.contains("\\begin"), "got: {lines:#?}");
}
#[test]
fn latex_in_code_span_left_verbatim() {
// Code spans are verbatim: `\(…\)` inside backticks must NOT convert.
let lines = pretty_lines("inline `\\(x\\)` code\n\n");
let joined = lines.join("\n");
assert!(joined.contains("\\(x\\)"), "code must stay raw: {lines:#?}");
}
#[test]
fn display_math_in_blockquote_renders() {
let lines = pretty_lines("> Einstein: $$E = mc^2$$\n\n");
let joined = lines.join("\n");
assert!(joined.contains("E = mc²"), "got: {lines:#?}");
}
#[test]
fn oversized_inline_math_falls_back_to_code_styling() {
let body = "x".repeat(crate::latex::MAX_MATH_SOURCE_LEN + 10);
let text = format!("Big ${body}$ end.\n\n");
let lines = pretty_lines(&text);
let joined = lines.join("\n");
// Content is preserved verbatim (code-style fallback), delimiters
// hidden in pretty mode.
assert!(joined.contains(&body), "fallback must keep raw content");
}
#[test]
fn bracket_math_inside_link_label_keeps_link_target() {
// Option A normalizes `\[x\]` → `$$x$$` everywhere outside code, so (like
// a literal `$$…$$`) display math inside a link label now converts. This
// construct — display math inside a link label — is degenerate and
// exceedingly rare in model output; the invariant we keep is that the
// link target survives.
let lines = pretty_lines("See [\\[x\\] notes](https://example.com) now.\n\n");
let joined = lines.join("\n");
assert!(
joined.contains("https://example.com"),
"link must survive: {lines:#?}"
);
}
#[test]
fn unclosed_math_renders_without_panic() {
for text in [
"open $a + b\n\n",
"open $$a + b\n\n",
"open \\(a + b\n\n",
"open \\[a + b\n\n",
"$$\n\\frac{1}{\n\n",
"\\]\n\n",
"\\)\n\n",
] {
let _ = pretty_lines(text);
let _ = render_markdown(text, test_style::STYLE, true, None);
let _ = render_markdown(text, test_style::STYLE, false, None);
}
}
#[test]
fn ansi_render_includes_math_block_lines() {
let (out, _) = render_markdown("before $$x^2$$ after\n\n", test_style::STYLE, true, None);
assert!(out.contains(""), "got: {out:?}");
// Block content starts on its own line.
let plain = out.replace("\x1b[0m", "");
let math_line = plain
.lines()
.find(|l| l.contains(""))
.expect("math line in ANSI output");
assert!(math_line.trim_start().starts_with(""), "got: {out:?}");
}
#[test]
fn multiple_inline_math_spans_in_one_paragraph() {
let lines = pretty_lines("Both $a^2$ and \\(b_1\\) and $c \\ne d$ work.\n\n");
assert_eq!(
lines[0], "Both a² and b₁ and c ≠ d work.",
"got: {lines:#?}"
);
}
#[test]
fn greek_and_symbols_inline() {
let lines =
pretty_lines("Rate $\\lambda \\approx 0.5$ and set $S \\subseteq \\mathbb{R}^n$.\n\n");
assert_eq!(lines[0], "Rate λ ≈ 0.5 and set S ⊆ ℝⁿ.", "got: {lines:#?}");
}
}
/// Tests for HTML character-entity decoding in prose (`&lt;` → `<`, etc.).
#[cfg(test)]
mod entity_tests {
use crate::style::test_style;
use crate::{render_markdown, render_markdown_ratatui_full};
fn lines_to_text(lines: &[ratatui::text::Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
}
fn pretty_lines(text: &str) -> Vec<String> {
let (output, _) = render_markdown_ratatui_full(text, test_style::STYLE, true, None);
lines_to_text(&output.lines)
}
fn raw_lines(text: &str) -> Vec<String> {
let (output, _) = render_markdown_ratatui_full(text, test_style::STYLE, false, None);
lines_to_text(&output.lines)
}
#[test]
fn lt_gt_amp_decoded_in_prose() {
let lines = pretty_lines("Use &lt;tag&gt; with a &amp; b.\n\n");
assert_eq!(lines[0], "Use <tag> with a & b.", "got: {lines:#?}");
}
#[test]
fn multiple_entities_one_paragraph() {
let lines = pretty_lines("1 &lt; 2 &amp;&amp; 3 &gt; 2\n\n");
assert_eq!(lines[0], "1 < 2 && 3 > 2", "got: {lines:#?}");
}
#[test]
fn quote_and_apostrophe_entities() {
let lines = pretty_lines("&quot;hello&quot; &amp; &#39;world&#39;\n\n");
assert_eq!(lines[0], "\"hello\" & 'world'", "got: {lines:#?}");
}
#[test]
fn numeric_decimal_and_hex_entities() {
// &#60; = '<', &#x3e; = '>'
let lines = pretty_lines("a &#60;b&#x3e; c\n\n");
assert_eq!(lines[0], "a <b> c", "got: {lines:#?}");
}
#[test]
fn full_html5_named_entities_decoded() {
// Beyond the XML core set: these must decode in prose just like they
// already do in table cells (via pulldown), keeping the two consistent.
let lines = pretty_lines("&mdash; &copy; &hellip; &rarr; &times;\n\n");
assert_eq!(lines[0], "— © … → ×", "got: {lines:#?}");
}
#[test]
fn nbsp_decodes_to_no_break_space() {
let lines = pretty_lines("a&nbsp;b\n\n");
assert_eq!(lines[0], "a\u{a0}b", "got: {lines:#?}");
}
#[test]
fn control_char_entities_are_not_injected() {
// ESC / BEL / NUL / CR must never be substituted into terminal output;
// the source stays literal instead.
for (src, literal) in [
("x &#27; y\n\n", "&#27;"),
("x &#x1b; y\n\n", "&#x1b;"),
("x &#7; y\n\n", "&#7;"),
("x &#0; y\n\n", "&#0;"),
] {
let lines = pretty_lines(src);
let joined = lines.join("\n");
assert!(
joined.contains(literal),
"control entity must stay literal: src={src:?} got={lines:#?}"
);
assert!(
!joined.chars().any(|c| c.is_control() && c != '\n'),
"no control char injected: src={src:?} got={lines:#?}"
);
}
}
#[test]
fn entity_inside_link_text_decodes_and_keeps_link() {
let lines = pretty_lines("See [a &lt; b](https://example.com) end.\n\n");
let joined = lines.join("\n");
assert!(joined.contains("a < b"), "link text decoded: {lines:#?}");
assert!(
joined.contains("https://example.com"),
"link url survives: {lines:#?}"
);
assert!(!joined.contains("&lt;"), "no literal entity: {lines:#?}");
}
#[test]
fn entity_inside_inline_math_does_not_corrupt() {
// The entity sits inside a `\(...\)` math span; the math transform owns
// those bytes, so the entity scan must not add an overlapping transform.
let lines = pretty_lines("eq \\(a &lt; b\\) end\n\n");
let joined = lines.join("\n");
assert!(joined.contains("end"), "trailing text intact: {lines:#?}");
// No doubled fragments from overlapping transforms.
assert!(!joined.contains("endend"), "no double emit: {lines:#?}");
}
#[test]
fn raw_mode_preserves_entity_source() {
let lines = raw_lines("Use &lt;tag&gt; here.\n\n");
assert!(
lines[0].contains("&lt;tag&gt;"),
"raw mode must keep source: {lines:#?}"
);
}
#[test]
fn entities_decoded_inside_emphasis_and_heading() {
let bold = pretty_lines("**a &lt; b**\n\n");
assert_eq!(bold[0], "a < b", "got: {bold:#?}");
let heading = pretty_lines("## Compare &lt;T&gt;\n\n");
assert!(
heading.iter().any(|l| l.contains("Compare <T>")),
"got: {heading:#?}"
);
}
#[test]
fn entities_left_literal_in_code() {
// Inline code and fenced blocks are intentionally verbatim.
let inline = pretty_lines("call `vec&lt;i32&gt;` now.\n\n");
assert!(
inline.iter().any(|l| l.contains("vec&lt;i32&gt;")),
"inline code stays literal: {inline:#?}"
);
let fenced = pretty_lines("```\nGeneric&lt;T&gt;\n```\n\n");
assert!(
fenced.iter().any(|l| l.contains("Generic&lt;T&gt;")),
"code block stays literal: {fenced:#?}"
);
}
#[test]
fn unknown_or_bare_ampersand_untouched() {
// No semicolon, unknown name, and a lone `&` must all pass through.
let lines = pretty_lines("Tom &amp Jerry &unknown; plain & text\n\n");
assert_eq!(
lines[0], "Tom &amp Jerry &unknown; plain & text",
"got: {lines:#?}"
);
}
#[test]
fn entity_in_table_cell_still_decodes() {
// Regression guard: the table cell path already decoded entities; this
// must keep working alongside the new prose path.
let lines = pretty_lines("| H |\n|---|\n| a &lt; b |\n\n");
let joined = lines.join("\n");
assert!(joined.contains("a < b"), "got: {lines:#?}");
}
#[test]
fn no_panic_on_entity_edge_cases() {
for text in [
"&\n\n",
"&;\n\n",
"&#;\n\n",
"&#x;\n\n",
"&#0;\n\n",
"&#27;\n\n",
"&#x1b;\n\n",
"trailing &lt",
"&lt;&gt;&amp;",
"&#xZZ;\n\n",
"&CounterClockwiseContourIntegral;\n\n",
// Multi-byte UTF-8 mixed with `&` in various positions: the inner
// loop only advances over ASCII bytes, so it must not slice
// through a multi-byte sequence.
"& é &lt; ñ\n\n",
"café &lt; thé\n\n",
"🦀 & 🦀\n\n",
"&amp;🦀&lt;\n\n",
// Repeated `&` runs (worst case for the O(n²) bound).
"&&&&&&&&&&&&\n\n",
&("&".repeat(200) + "\n\n"),
] {
let _ = pretty_lines(text);
let _ = render_markdown(text, test_style::STYLE, true, None);
let _ = render_markdown(text, test_style::STYLE, false, None);
}
}
}