2753 lines
112 KiB
Rust
2753 lines
112 KiB
Rust
//! 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: `` should not show `[img`.
|
||
#[test]
|
||
fn test_pretty_image_bracket_removed() {
|
||
let text = "An  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("x²"), "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("x²"), "got: {out:?}");
|
||
// Block content starts on its own line.
|
||
let plain = out.replace("\x1b[0m", "");
|
||
let math_line = plain
|
||
.lines()
|
||
.find(|l| l.contains("x²"))
|
||
.expect("math line in ANSI output");
|
||
assert!(math_line.trim_start().starts_with("x²"), "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 (`<` → `<`, 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 <tag> with a & 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 < 2 && 3 > 2\n\n");
|
||
assert_eq!(lines[0], "1 < 2 && 3 > 2", "got: {lines:#?}");
|
||
}
|
||
|
||
#[test]
|
||
fn quote_and_apostrophe_entities() {
|
||
let lines = pretty_lines(""hello" & 'world'\n\n");
|
||
assert_eq!(lines[0], "\"hello\" & 'world'", "got: {lines:#?}");
|
||
}
|
||
|
||
#[test]
|
||
fn numeric_decimal_and_hex_entities() {
|
||
// < = '<', > = '>'
|
||
let lines = pretty_lines("a <b> 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("— © … → ×\n\n");
|
||
assert_eq!(lines[0], "— © … → ×", "got: {lines:#?}");
|
||
}
|
||
|
||
#[test]
|
||
fn nbsp_decodes_to_no_break_space() {
|
||
let lines = pretty_lines("a 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  y\n\n", ""),
|
||
("x  y\n\n", ""),
|
||
("x  y\n\n", ""),
|
||
("x � y\n\n", "�"),
|
||
] {
|
||
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 < 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("<"), "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 < 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 <tag> here.\n\n");
|
||
assert!(
|
||
lines[0].contains("<tag>"),
|
||
"raw mode must keep source: {lines:#?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn entities_decoded_inside_emphasis_and_heading() {
|
||
let bold = pretty_lines("**a < b**\n\n");
|
||
assert_eq!(bold[0], "a < b", "got: {bold:#?}");
|
||
let heading = pretty_lines("## Compare <T>\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<i32>` now.\n\n");
|
||
assert!(
|
||
inline.iter().any(|l| l.contains("vec<i32>")),
|
||
"inline code stays literal: {inline:#?}"
|
||
);
|
||
let fenced = pretty_lines("```\nGeneric<T>\n```\n\n");
|
||
assert!(
|
||
fenced.iter().any(|l| l.contains("Generic<T>")),
|
||
"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 & Jerry &unknown; plain & text\n\n");
|
||
assert_eq!(
|
||
lines[0], "Tom & 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 < 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",
|
||
"�\n\n",
|
||
"\n\n",
|
||
"\n\n",
|
||
"trailing <",
|
||
"<>&",
|
||
"&#xZZ;\n\n",
|
||
"∳\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.
|
||
"& é < ñ\n\n",
|
||
"café < thé\n\n",
|
||
"🦀 & 🦀\n\n",
|
||
"&🦀<\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);
|
||
}
|
||
}
|
||
}
|