//! 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 { fn style_into(self) -> T; } impl StyleInto 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) { 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 { 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 = 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::::new(); let mut last_pos = 0; let mut replace: Option = None; let mut table_replace: Option = None; let mut mermaid_replace: Option = None; let mut current = (0..0, Style::new()); fn push( out: &mut String, current: &mut (Range, Style), text: &str, range: Range, 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) { // Build render events let render_events = self.build_render_events(); self.buffers.current_spans.clear(); self.buffers.active_highlights.clear(); let mut lines: Vec> = Vec::new(); let mut line_source_map: Vec = Vec::new(); let mut hyperlinks: Vec = Vec::new(); let mut last_pos = 0; let mut replace: Option = None; let mut table_replace: Option = None; let mut mermaid_replace: Option = 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 = 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 = 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 = 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 { lines .iter() .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect()) .collect() } /// A fenced `mermaid` block renders as a diagram in pretty mode. #[test] fn test_mermaid_block_renders_diagram() { let md = "```mermaid\ngraph TD\n A[Start] --> B[End]\n```\n"; let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None); let text = lines_to_text(&output.lines).join("\n"); assert!( text.contains('┌') || text.contains('╭'), "expected box-drawing, got:\n{text}" ); assert!(text.contains("Start") && text.contains("End"), "{text}"); assert!(text.contains('▼'), "expected an arrowhead, got:\n{text}"); assert!(!text.contains("```"), "fences should be hidden:\n{text}"); } /// A mermaid fence with trailing info tokens still renders a diagram. #[test] fn test_mermaid_block_with_info_extras_renders() { let md = "```mermaid theme=dark\ngraph TD\n A[X] --> B[Y]\n```\n"; let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None); let text = lines_to_text(&output.lines).join("\n"); assert!( text.contains('▼'), "info extras should still draw a diagram:\n{text}" ); } /// Raw mode shows the mermaid source instead of the diagram. #[test] fn test_mermaid_block_raw_mode_shows_source() { let md = "```mermaid\ngraph TD\n A[Start] --> B[End]\n```\n"; let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, false, None); let text = lines_to_text(&output.lines).join("\n"); assert!(text.contains("graph TD"), "raw should show source:\n{text}"); assert!( !text.contains('▼'), "raw should not draw a diagram:\n{text}" ); } /// Pretty mode must remove the opening `[` from `[text](url)` links. /// Regression test: apply_transforms treated replace-with-empty-string /// as "no transform applied" because it checked `result.is_empty()`. #[test] fn test_pretty_link_bracket_removed() { let text = "Here is a [link](https://example.com) in text.\n\n"; let (output, _) = render_markdown_ratatui_full(text, test_style::STYLE, true, None); let lines = lines_to_text(&output.lines); assert!( !lines[0].contains("[link"), "Pretty mode should remove '[' from link. Got: {:?}", lines[0] ); assert!( lines[0].contains("link (https://example.com)"), "Pretty mode should render 'link (url)'. Got: {:?}", lines[0] ); } /// Same regression for images: `![img](src)` should not show `[img`. #[test] fn test_pretty_image_bracket_removed() { let text = "An ![image](src.png) here.\n\n"; let (output, _) = render_markdown_ratatui_full(text, test_style::STYLE, true, None); let lines = lines_to_text(&output.lines); let img_line = &lines[0]; assert!( !img_line.contains("[image"), "Pretty mode should remove '[' from image. Got: {:?}", img_line ); } /// Regression: `count_newlines_in_range` panics when a checkpoint byte /// offset from a thematic break falls inside a multi-byte character in /// subsequent content (e.g., a 4-byte emoji like 📐). /// /// Minimal repro: thematic break `---` followed by heading with emoji. /// The checkpoint creates a byte offset that lands mid-emoji when used /// to slice `self.text` in `text[from..to]`. /// Nested blockquote with paragraph break and list inside inner quote. #[test] fn test_nested_blockquote_with_list() { let md = "> Foo\n>\n> > Bar\n> >\n> > - Baz\n"; let (raw_output, _) = render_markdown_ratatui_full(md, test_style::STYLE, false, None); assert_eq!( lines_to_text(&raw_output.lines), vec!["> Foo", ">", "> > Bar", "> >", "> > - Baz"], "raw mode", ); let (pretty_output, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None); assert_eq!( lines_to_text(&pretty_output.lines), vec!["│ Foo", "│", "│ │ Bar", "│ │", "│ │ • Baz"], "pretty mode", ); } #[test] fn test_emoji_after_thematic_break_does_not_panic() { // "---\n\n## 📐 H\n\n" — 📐 is at bytes 8..12, checkpoint offset // lands at byte 10 (inside the emoji), causing a panic in // count_newlines_in_range which does text[from..to]. let md = "---\n\n## 📐 H\n\n"; let (_output, _cp) = render_markdown_ratatui_full(md, test_style::STYLE, true, None); } #[test] fn test_list_followed_by_code_block_has_separator() { // A list item followed by a code block should have a blank line between them let md = "1. Hello\n```python\nworld\n```\n"; let (output, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None); let text = lines_to_text(&output.lines); eprintln!("Lines: {text:#?}"); // Find the list item line and the code block line let hello_idx = text.iter().position(|l| l.contains("Hello")).unwrap(); let world_idx = text.iter().position(|l| l.contains("world")).unwrap(); // There should be at least one blank line between them assert!( world_idx - hello_idx >= 2, "Expected blank line between list item and code block. \ hello at {hello_idx}, world at {world_idx}. Lines: {text:#?}" ); } #[test] fn test_code_block_empty_line_has_bg() { use ratatui::style::Color; // Create a style with a visible code_background let mut style = test_style::STYLE; style.code_background = anstyle::Style::new() .bg_color(Some(anstyle::Color::Rgb(anstyle::RgbColor(30, 30, 46)))); let md = "```\nline1\n\nline3\n```\n"; let (output, _) = render_markdown_ratatui_full(md, style, true, None); let expected_bg = Color::Rgb(30, 30, 46); // All lines inside the code block should have the bg set for (i, line) in output.lines.iter().enumerate() { assert_eq!( line.style.bg, Some(expected_bg), "Line {i} ({:?}) should have code_background, got {:?}", lines_to_text(std::slice::from_ref(line))[0], line.style.bg, ); } } /// Regression: an unterminated bare fence with no trailing newline (the tail of a /// streamed message) must keep code_background on its final line. #[test] fn test_unterminated_untagged_fence_final_line_has_bg() { use ratatui::style::Color; let mut style = test_style::STYLE; style.code_background = anstyle::Style::new() .bg_color(Some(anstyle::Color::Rgb(anstyle::RgbColor(30, 30, 46)))); let md = "```\nline1\n\nfinal line"; let (output, _) = render_markdown_ratatui_full(md, style, true, None); let texts = lines_to_text(&output.lines); assert!( texts.last().is_some_and(|l| l.contains("final line")), "expected the newline-less final line in output: {texts:#?}" ); let expected_bg = Color::Rgb(30, 30, 46); for (i, line) in output.lines.iter().enumerate() { assert_eq!( line.style.bg, Some(expected_bg), "Line {i} ({:?}) should have code_background", texts[i], ); } } /// Tables wider than max_table_width should be constrained to fit. #[test] fn test_table_constrained_to_max_width() { use unicode_width::UnicodeWidthStr; let md = "| Column A | Column B | Column C |\n|----------|----------|----------|\n| value 1 | value 2 | value 3 |\n\n"; // Render without constraint — table uses natural widths let (output_full, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None); let full_lines = lines_to_text(&output_full.lines); let full_max_width = full_lines.iter().map(|l| l.width()).max().unwrap_or(0); // Render with narrow constraint let narrow = 30; assert!( full_max_width > narrow, "Table should be wider than {narrow} naturally" ); let mut buffers = crate::MarkdownBuffers::new(); let (output_narrow, _) = crate::render_markdown_ratatui_with_buffers_width( md, test_style::STYLE, true, &mut buffers, None, Some(narrow), ); let narrow_lines = lines_to_text(&output_narrow.lines); let narrow_max_width = narrow_lines.iter().map(|l| l.width()).max().unwrap_or(0); assert!( narrow_max_width <= narrow, "Constrained table should fit within {narrow} columns, got {narrow_max_width}. Lines: {narrow_lines:#?}" ); // All table lines should still have consistent widths let table_widths: Vec = 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 { 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 { 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. ``) 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 | 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 | 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 |\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(""), "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 | Option |\n| Box | Result |\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 | Option |\n| Box | Result |\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 |\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
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("
"), "literal
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
b
c
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 ["
", "
", "
"] { 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
world |\n\n"; let (output, _) = crate::render_markdown(md, test_style::STYLE, true, None); assert!( !output.contains("
"), "literal
in ANSI output: {output}" ); } #[test] fn test_br_tag_outside_table() { let md = "hello
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("
"), "literal
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 \"\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 \" \" 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() { // arrives as Event::InlineHtml; the following `\n` is the soft break. let md = "Foo bar \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(" 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
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("
"), "{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 { lines .iter() .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect()) .collect() } fn pretty_lines(text: &str) -> Vec { 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 { lines .iter() .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect()) .collect() } fn pretty_lines(text: &str) -> Vec { let (output, _) = render_markdown_ratatui_full(text, test_style::STYLE, true, None); lines_to_text(&output.lines) } fn raw_lines(text: &str) -> Vec { 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 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 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 ")), "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); } } }