grok-build-upstream-mirror/crates/codegen/xai-grok-markdown/src/render.rs
grokkybara[bot] c68e39f604 Publish harness and TUI open-source
initial sync from the monorepo
2026-07-16 06:46:02 +01:00

2753 lines
112 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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);
}
}
}