use pulldown_cmark::{CodeBlockKind, CowStr, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthStr;
use crate::config::RColorConfig;
use crate::highlighter::{TokenType, tokenize_r};
use crate::pager::style_convert::nu_ansi_color_to_ratatui;
pub fn render_markdown(
input: &str,
default_code_lang: Option<&str>,
wrap_width: Option<usize>,
) -> Vec<Line<'static>> {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
let parser = Parser::new_ext(input, options);
let mut writer = Writer::new(parser, default_code_lang.map(|s| s.to_string()), wrap_width);
writer.run();
writer.lines
}
struct Styles {
h1: Style,
h2: Style,
h3: Style,
code: Style,
emphasis: Style,
strong: Style,
link: Style,
blockquote_prefix: Style,
code_block: Style,
code_block_bg: Color,
}
impl Default for Styles {
fn default() -> Self {
Self {
h1: Style::new().bold().underlined(),
h2: Style::new().bold(),
h3: Style::new().italic(),
code: Style::new().cyan(),
emphasis: Style::new().italic(),
strong: Style::new().bold(),
link: Style::new().cyan().underlined(),
blockquote_prefix: Style::new().green(),
code_block: Style::new().dim(),
code_block_bg: Color::Indexed(236),
}
}
}
fn token_type_to_style(tt: TokenType) -> Style {
static CONFIG: std::sync::LazyLock<RColorConfig> =
std::sync::LazyLock::new(RColorConfig::default);
let color = match tt {
TokenType::Comment => CONFIG.comment,
TokenType::String => CONFIG.string,
TokenType::Number => CONFIG.number,
TokenType::Keyword => CONFIG.keyword,
TokenType::Constant => CONFIG.constant,
TokenType::Operator => CONFIG.operator,
TokenType::Punctuation => CONFIG.punctuation,
TokenType::Identifier => CONFIG.identifier,
TokenType::Whitespace | TokenType::Other => return Style::default(),
};
match color {
nu_ansi_term::Color::Default => Style::default(),
c => Style::new().fg(nu_ansi_color_to_ratatui(c)),
}
}
fn is_r_language(lang: &str) -> bool {
matches!(lang, "r" | "R")
}
struct Writer<'a, I: Iterator<Item = Event<'a>>> {
iter: I,
lines: Vec<Line<'static>>,
styles: Styles,
default_code_lang: Option<String>,
wrap_width: Option<usize>,
inline_styles: Vec<Style>,
current_spans: Vec<Span<'static>>,
in_heading: Option<HeadingLevel>,
in_code_block: bool,
code_block_lang: Option<String>,
code_block_buffer: String,
list_indices: Vec<Option<u64>>,
list_depth: usize,
blockquote_depth: usize,
link: Option<String>,
needs_newline: bool,
pending_marker: bool,
has_output: bool,
in_table: bool,
table_rows: Vec<Vec<Vec<Span<'static>>>>,
table_current_row: Vec<Vec<Span<'static>>>,
table_cell_spans: Vec<Span<'static>>,
}
impl<'a, I: Iterator<Item = Event<'a>>> Writer<'a, I> {
fn new(iter: I, default_code_lang: Option<String>, wrap_width: Option<usize>) -> Self {
Self {
iter,
lines: Vec::new(),
styles: Styles::default(),
default_code_lang,
wrap_width,
inline_styles: Vec::new(),
current_spans: Vec::new(),
in_heading: None,
in_code_block: false,
code_block_lang: None,
code_block_buffer: String::new(),
list_indices: Vec::new(),
list_depth: 0,
blockquote_depth: 0,
link: None,
needs_newline: false,
pending_marker: false,
has_output: false,
in_table: false,
table_rows: Vec::new(),
table_current_row: Vec::new(),
table_cell_spans: Vec::new(),
}
}
fn run(&mut self) {
while let Some(ev) = self.iter.next() {
self.handle_event(ev);
}
self.flush_line();
}
fn handle_event(&mut self, event: Event<'a>) {
match event {
Event::Start(tag) => self.start_tag(tag),
Event::End(tag) => self.end_tag(tag),
Event::Text(text) => self.on_text(text),
Event::Code(code) => self.on_inline_code(code),
Event::SoftBreak => self.on_soft_break(),
Event::HardBreak => self.on_hard_break(),
Event::Rule => self.on_rule(),
Event::Html(html) => self.on_html(html),
Event::InlineHtml(html) => self.on_inline_html(html),
Event::FootnoteReference(_)
| Event::TaskListMarker(_)
| Event::InlineMath(_)
| Event::DisplayMath(_) => {}
}
}
fn start_tag(&mut self, tag: Tag<'a>) {
match tag {
Tag::Paragraph => {
if self.in_table {
return;
}
self.ensure_blank_line_before_block();
}
Tag::Heading { level, .. } => {
self.ensure_blank_line_before_block();
self.in_heading = Some(level);
let prefix = match level {
HeadingLevel::H1 => "# ",
HeadingLevel::H2 => "## ",
HeadingLevel::H3 => "### ",
HeadingLevel::H4 => "#### ",
HeadingLevel::H5 => "##### ",
HeadingLevel::H6 => "###### ",
};
let style = match level {
HeadingLevel::H1 => self.styles.h1,
HeadingLevel::H2 => self.styles.h2,
_ => self.styles.h3,
};
self.push_span(Span::styled(prefix.to_string(), style));
self.inline_styles.push(style);
}
Tag::BlockQuote(_) => {
self.ensure_blank_line_before_block();
self.blockquote_depth += 1;
}
Tag::CodeBlock(kind) => {
self.ensure_blank_line_before_block();
self.in_code_block = true;
let explicit_lang = match kind {
CodeBlockKind::Fenced(lang) => {
let lang = lang.split_whitespace().next().unwrap_or("");
if lang.is_empty() {
None
} else {
Some(lang.to_string())
}
}
CodeBlockKind::Indented => None,
};
self.code_block_lang = explicit_lang.or_else(|| self.default_code_lang.clone());
self.code_block_buffer.clear();
}
Tag::List(start) => {
if self.list_depth == 0 {
self.ensure_blank_line_before_block();
} else {
self.flush_line();
}
self.list_indices.push(start);
self.list_depth += 1;
}
Tag::Item => {
self.flush_line();
self.pending_marker = true;
}
Tag::Emphasis => {
self.inline_styles.push(self.styles.emphasis);
}
Tag::Strong => {
self.inline_styles.push(self.styles.strong);
}
Tag::Strikethrough => {
self.inline_styles.push(Style::new().crossed_out());
}
Tag::Link { dest_url, .. } => {
self.link = Some(dest_url.to_string());
}
Tag::Table(_) => {
self.ensure_blank_line_before_block();
self.in_table = true;
self.table_rows.clear();
}
Tag::TableHead => {
self.table_current_row.clear();
}
Tag::TableRow => {
self.table_current_row.clear();
}
Tag::TableCell => {
self.table_cell_spans.clear();
}
Tag::HtmlBlock
| Tag::Image { .. }
| Tag::FootnoteDefinition(_)
| Tag::MetadataBlock(_)
| Tag::DefinitionList
| Tag::DefinitionListTitle
| Tag::DefinitionListDefinition
| Tag::Superscript
| Tag::Subscript => {}
}
}
fn end_tag(&mut self, tag: TagEnd) {
match tag {
TagEnd::Paragraph => {
if self.in_table {
return;
}
self.flush_line();
self.needs_newline = true;
}
TagEnd::Heading(_) => {
self.inline_styles.pop();
self.flush_line();
self.in_heading = None;
self.needs_newline = true;
}
TagEnd::BlockQuote(_) => {
self.flush_line();
self.blockquote_depth = self.blockquote_depth.saturating_sub(1);
self.needs_newline = true;
}
TagEnd::CodeBlock => {
self.flush_code_block();
self.in_code_block = false;
self.code_block_lang = None;
self.needs_newline = true;
}
TagEnd::List(_) => {
self.flush_line();
self.list_indices.pop();
self.list_depth = self.list_depth.saturating_sub(1);
if self.list_depth == 0 {
self.needs_newline = true;
}
}
TagEnd::Item => {
self.flush_line();
if let Some(Some(idx)) = self.list_indices.last_mut() {
*idx += 1;
}
}
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => {
self.inline_styles.pop();
}
TagEnd::Link => {
if let Some(url) = self.link.take() {
self.push_span(Span::styled(format!(" ({})", url), self.styles.link));
}
}
TagEnd::Table => {
self.render_table();
self.in_table = false;
self.needs_newline = true;
}
TagEnd::TableHead => {
let row = std::mem::take(&mut self.table_current_row);
self.table_rows.insert(0, row); }
TagEnd::TableRow => {
let row = std::mem::take(&mut self.table_current_row);
self.table_rows.push(row);
}
TagEnd::TableCell => {
let spans = std::mem::take(&mut self.table_cell_spans);
self.table_current_row.push(spans);
}
TagEnd::HtmlBlock
| TagEnd::Image
| TagEnd::FootnoteDefinition
| TagEnd::MetadataBlock(_)
| TagEnd::DefinitionList
| TagEnd::DefinitionListTitle
| TagEnd::DefinitionListDefinition
| TagEnd::Superscript
| TagEnd::Subscript => {}
}
}
fn on_text(&mut self, text: CowStr<'a>) {
if self.in_table {
let style = self.current_style();
self.table_cell_spans
.push(Span::styled(text.to_string(), style));
return;
}
if self.in_code_block {
self.code_block_buffer.push_str(&text);
return;
}
self.emit_prefix_if_needed();
let style = self.current_style();
self.push_span(Span::styled(text.to_string(), style));
}
fn on_inline_code(&mut self, code: CowStr<'a>) {
if self.in_table {
self.table_cell_spans
.push(Span::styled(code.to_string(), self.styles.code));
return;
}
self.emit_prefix_if_needed();
self.push_span(Span::styled(code.to_string(), self.styles.code));
}
fn on_soft_break(&mut self) {
if self.in_table {
self.table_cell_spans.push(Span::raw(" "));
return;
}
self.push_span(Span::raw(" "));
}
fn on_hard_break(&mut self) {
self.flush_line();
}
fn on_html(&mut self, html: CowStr<'a>) {
for line in html.lines() {
if !line.trim().is_empty() {
self.emit_prefix_if_needed();
self.push_span(Span::raw(line.to_string()));
self.flush_line();
}
}
}
fn on_inline_html(&mut self, html: CowStr<'a>) {
let trimmed = html.trim();
if trimmed.eq_ignore_ascii_case("<br>")
|| trimmed.eq_ignore_ascii_case("<br/>")
|| trimmed.eq_ignore_ascii_case("<br />")
{
if self.in_table {
self.table_cell_spans.push(Span::raw("\n"));
} else {
self.flush_line();
}
return;
}
self.on_text(html);
}
fn on_rule(&mut self) {
self.flush_line();
if self.has_output {
self.push_blank_line();
}
self.lines.push(Line::from("———"));
self.has_output = true;
self.needs_newline = true;
}
fn current_style(&self) -> Style {
let mut s = Style::default();
for sty in &self.inline_styles {
s = s.patch(*sty);
}
s
}
fn push_span(&mut self, span: Span<'static>) {
self.current_spans.push(span);
}
fn emit_prefix_if_needed(&mut self) {
if !self.current_spans.is_empty() {
return;
}
for _ in 0..self.blockquote_depth {
self.push_span(Span::styled("> ", self.styles.blockquote_prefix));
}
if self.list_depth > 0 {
let indent_levels = self.list_depth.saturating_sub(1);
if indent_levels > 0 {
self.push_span(Span::raw(" ".repeat(indent_levels)));
}
if self.pending_marker {
if let Some(maybe_idx) = self.list_indices.last() {
match maybe_idx {
Some(idx) => {
self.push_span(Span::raw(format!("{}. ", idx)));
}
None => {
self.push_span(Span::raw("- "));
}
}
}
self.pending_marker = false;
} else {
self.push_span(Span::raw(" "));
}
}
}
fn flush_line(&mut self) {
if self.current_spans.is_empty() {
return;
}
let spans = std::mem::take(&mut self.current_spans);
if let Some(width) = self.wrap_width
&& !self.in_code_block
&& !self.in_table
&& self.in_heading.is_none()
{
let indent = self.continuation_indent();
let wrapped = super::text_utils::wrap_spans(&spans, width, indent);
for line_spans in wrapped {
self.lines.push(Line::from(line_spans));
}
self.has_output = true;
return;
}
self.lines.push(Line::from(spans));
self.has_output = true;
}
fn continuation_indent(&self) -> usize {
let mut indent = self.blockquote_depth * 2; if self.list_depth > 0 {
indent += self.list_depth.saturating_sub(1) * 2;
if let Some(Some(idx)) = self.list_indices.last() {
let digits = if *idx == 0 {
1
} else {
(*idx).ilog10() as usize + 1
};
indent += digits + 2; } else {
indent += 2; }
}
indent
}
fn flush_code_line(&mut self) {
let spans = std::mem::take(&mut self.current_spans);
let line = Line::from(spans).style(Style::new().bg(self.styles.code_block_bg));
self.lines.push(line);
self.has_output = true;
}
fn push_blank_line(&mut self) {
self.lines.push(Line::from(""));
}
fn ensure_blank_line_before_block(&mut self) {
if self.needs_newline && self.has_output {
self.flush_line();
self.push_blank_line();
self.needs_newline = false;
}
}
fn flush_code_block(&mut self) {
let buffer = std::mem::take(&mut self.code_block_buffer);
let source = buffer.strip_suffix('\n').unwrap_or(&buffer);
let use_r_highlight = self.code_block_lang.as_deref().is_some_and(is_r_language);
if use_r_highlight {
let tokens = tokenize_r(source);
self.emit_prefix_if_needed();
for token in &tokens {
debug_assert!(
token.start <= source.len() && token.end <= source.len(),
"token [{}, {}) out of bounds for source len {}",
token.start,
token.end,
source.len()
);
let text = &source[token.start..token.end];
let style = token_type_to_style(token.token_type);
let parts: Vec<&str> = text.split('\n').collect();
for (i, part) in parts.iter().enumerate() {
if i > 0 {
self.flush_code_line();
self.emit_prefix_if_needed();
}
if !part.is_empty() {
self.push_span(Span::styled(part.to_string(), style));
}
}
}
self.flush_code_line();
} else {
for line_text in source.split('\n') {
self.emit_prefix_if_needed();
self.push_span(Span::styled(line_text.to_string(), self.styles.code_block));
self.flush_code_line();
}
}
}
fn split_cell_lines(cell: &[Span<'static>]) -> Vec<Vec<Span<'static>>> {
let mut lines: Vec<Vec<Span<'static>>> = vec![Vec::new()];
for span in cell {
if span.content.as_ref() == "\n" {
lines.push(Vec::new());
} else {
lines.last_mut().unwrap().push(span.clone());
}
}
lines
}
fn spans_width(spans: &[Span<'static>]) -> usize {
spans
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum()
}
fn render_table(&mut self) {
if self.table_rows.is_empty() {
return;
}
let n_cols = self.table_rows.iter().map(|r| r.len()).max().unwrap_or(0);
if n_cols == 0 {
return;
}
let mut split_rows: Vec<Vec<Vec<Vec<Span<'static>>>>> = self
.table_rows
.iter()
.map(|row| {
row.iter()
.map(|cell| Self::split_cell_lines(cell))
.collect()
})
.collect();
let mut col_widths = vec![0usize; n_cols];
for row in &split_rows {
for (col_idx, cell_lines) in row.iter().enumerate() {
for sub_line in cell_lines {
let w = Self::spans_width(sub_line);
col_widths[col_idx] = col_widths[col_idx].max(w);
}
}
}
if let Some(wrap_width) = self.wrap_width {
let decoration = self.blockquote_depth * 2 + 2 + n_cols * 3;
let available = wrap_width.saturating_sub(decoration);
let total_natural: usize = col_widths.iter().sum();
if total_natural > available && available > 0 {
let target_widths = distribute_column_widths(&col_widths, available);
for row in &mut split_rows {
for (col_idx, cell_lines) in row.iter_mut().enumerate() {
let target = target_widths[col_idx];
if target == 0 {
continue;
}
let mut new_lines: Vec<Vec<Span<'static>>> = Vec::new();
for sub_line in cell_lines.drain(..) {
let wrapped = super::text_utils::wrap_spans(&sub_line, target, 0);
new_lines.extend(wrapped);
}
*cell_lines = new_lines;
}
}
col_widths = target_widths;
}
}
for (row_idx, row) in split_rows.iter().enumerate() {
let max_sub_lines = row.iter().map(|cell| cell.len()).max().unwrap_or(1);
for sub_line_idx in 0..max_sub_lines {
let mut spans: Vec<Span<'static>> = Vec::new();
for _ in 0..self.blockquote_depth {
spans.push(Span::styled("> ", self.styles.blockquote_prefix));
}
spans.push(Span::raw("| "));
for (col_idx, cell_lines) in row.iter().enumerate() {
let target = col_widths.get(col_idx).copied().unwrap_or(0);
if let Some(sub_line) = cell_lines.get(sub_line_idx) {
let w = Self::spans_width(sub_line);
for s in sub_line {
spans.push(s.clone());
}
let pad = target.saturating_sub(w);
if pad > 0 {
spans.push(Span::raw(" ".repeat(pad)));
}
} else {
spans.push(Span::raw(" ".repeat(target)));
}
spans.push(Span::raw(" | "));
}
self.lines.push(Line::from(spans));
self.has_output = true;
}
if row_idx == 0 {
let mut sep_spans: Vec<Span<'static>> = Vec::new();
for _ in 0..self.blockquote_depth {
sep_spans.push(Span::styled("> ", self.styles.blockquote_prefix));
}
sep_spans.push(Span::raw("| "));
for (col_idx, w) in col_widths.iter().enumerate() {
sep_spans.push(Span::raw("-".repeat(*w)));
if col_idx + 1 < n_cols {
sep_spans.push(Span::raw(" | "));
}
}
sep_spans.push(Span::raw(" |"));
self.lines.push(Line::from(sep_spans));
}
}
}
}
fn distribute_column_widths(natural: &[usize], available: usize) -> Vec<usize> {
let n = natural.len();
if n == 0 {
return vec![];
}
if available <= n {
let mut widths = vec![0usize; n];
for w in widths.iter_mut().take(available) {
*w = 1;
}
return widths;
}
let mut result = natural.to_vec();
let mut locked = vec![false; n];
let mut locked_total = 0usize;
let mut unlocked_count = n;
loop {
let remaining = available.saturating_sub(locked_total);
let share = remaining.checked_div(unlocked_count).unwrap_or(0);
let mut changed = false;
for i in 0..n {
if !locked[i] && result[i] <= share {
locked[i] = true;
locked_total += result[i];
unlocked_count -= 1;
changed = true;
}
}
if !changed {
break;
}
}
if unlocked_count > 0 {
let remaining = available.saturating_sub(locked_total);
let share = remaining.checked_div(unlocked_count).unwrap_or(0);
let extra = remaining % unlocked_count;
let mut idx = 0;
for i in 0..n {
if !locked[i] {
result[i] = share + if idx < extra { 1 } else { 0 };
idx += 1;
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
fn render_plain(input: &str) -> Vec<String> {
render_markdown(input, None, None)
.iter()
.map(|line| {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect()
}
#[test]
fn plain_text() {
let lines = render_plain("Hello world");
assert_eq!(lines, vec!["Hello world"]);
}
#[test]
fn heading_prefix() {
let lines = render_plain("# Title\n\nBody");
assert_eq!(lines, vec!["# Title", "", "Body"]);
}
#[test]
fn heading_levels() {
let lines = render_plain("## Sub\n\n### Third");
assert_eq!(lines, vec!["## Sub", "", "### Third"]);
}
#[test]
fn emphasis_and_strong() {
let lines = render_markdown("*em* **strong**", None, None);
assert_eq!(lines.len(), 1);
let spans = &lines[0].spans;
assert!(spans.len() >= 2);
}
#[test]
fn inline_code() {
let lines = render_markdown("Use `print()`", None, None);
assert_eq!(lines.len(), 1);
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(text, "Use print()");
}
#[test]
fn code_block() {
let input = "```r\nx <- 1\ny <- 2\n```";
let lines = render_plain(input);
assert!(lines.contains(&"x <- 1".to_string()));
assert!(lines.contains(&"y <- 2".to_string()));
}
#[test]
fn unordered_list() {
let input = "- one\n- two\n- three";
let lines = render_plain(input);
assert_eq!(lines, vec!["- one", "- two", "- three"]);
}
#[test]
fn ordered_list() {
let input = "1. first\n2. second\n3. third";
let lines = render_plain(input);
assert_eq!(lines, vec!["1. first", "2. second", "3. third"]);
}
#[test]
fn nested_list() {
let input = "- outer\n - inner";
let lines = render_plain(input);
assert_eq!(lines, vec!["- outer", " - inner"]);
}
#[test]
fn blockquote() {
let input = "> quoted text";
let lines = render_plain(input);
assert_eq!(lines, vec!["> quoted text"]);
}
#[test]
fn simple_table() {
let input = "| A | B |\n|---|---|\n| 1 | 2 |";
let lines = render_plain(input);
assert_eq!(lines.len(), 3);
assert!(lines[0].contains("A"));
assert!(lines[0].contains("B"));
assert!(lines[1].contains("-"));
assert!(lines[2].contains("1"));
assert!(lines[2].contains("2"));
}
#[test]
fn link_rendering() {
let input = "[click here](https://example.com)";
let lines = render_plain(input);
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("click here"));
assert!(lines[0].contains("https://example.com"));
}
#[test]
fn horizontal_rule() {
let input = "before\n\n---\n\nafter";
let lines = render_plain(input);
assert!(lines.contains(&"———".to_string()));
}
#[test]
fn table_with_br_tags() {
let input = "| Arg | Desc |\n|---|---|\n| x | first<br>second |";
let lines = render_plain(input);
assert!(lines.len() >= 4); assert!(lines[2].contains("first"));
assert!(lines[3].contains("second"));
}
#[test]
fn inline_html_br_outside_table() {
let input = "line one<br>line two";
let lines = render_plain(input);
assert_eq!(lines, vec!["line one", "line two"]);
}
#[test]
fn r_code_block_syntax_highlight() {
let input = "```r\nx <- 42\n```";
let lines = render_markdown(input, None, None);
let text: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(text.contains("x <- 42"));
let code_line = lines.iter().find(|l| {
let t: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
t.contains("<-")
});
assert!(code_line.is_some(), "Should have a line with <-");
let code_line = code_line.unwrap();
assert!(
code_line.spans.len() >= 3,
"R code should be tokenized into multiple spans, got {}",
code_line.spans.len()
);
let op_span = code_line
.spans
.iter()
.find(|s| s.content.as_ref().contains("<-"));
assert!(op_span.is_some(), "Should have an <- operator span");
assert_eq!(
op_span.unwrap().style.fg,
Some(Color::Yellow),
"Operator <- should be Yellow"
);
let num_span = code_line.spans.iter().find(|s| s.content.as_ref() == "42");
assert!(num_span.is_some(), "Should have a 42 number span");
assert_eq!(
num_span.unwrap().style.fg,
Some(Color::LightMagenta),
"Number 42 should be LightMagenta"
);
}
#[test]
fn non_r_code_block_uses_dim_style() {
let input = "```python\nprint('hello')\n```";
let lines = render_markdown(input, None, None);
let code_line = lines.iter().find(|l| {
let t: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
t.contains("print")
});
assert!(code_line.is_some());
let code_line = code_line.unwrap();
assert_eq!(
code_line.spans.len(),
1,
"Non-R code should be a single span"
);
assert!(
code_line.spans[0]
.style
.add_modifier
.contains(ratatui::style::Modifier::DIM),
"Non-R code should use DIM style"
);
}
#[test]
fn r_code_block_multiline() {
let input = "```r\nif (TRUE) {\n print(x)\n}\n```";
let lines = render_markdown(input, None, None);
let texts: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert!(texts.iter().any(|t| t.contains("if")));
assert!(texts.iter().any(|t| t.contains("print")));
let if_line = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.as_ref() == "if"));
assert!(if_line.is_some());
let if_span = if_line
.unwrap()
.spans
.iter()
.find(|s| s.content.as_ref() == "if");
assert_eq!(
if_span.unwrap().style.fg,
Some(Color::LightBlue),
"Keyword 'if' should be LightBlue"
);
}
#[test]
fn r_code_block_with_comments() {
let input = "```r\n# A comment\nx <- 1\n```";
let lines = render_markdown(input, None, None);
let comment_line = lines.iter().find(|l| {
l.spans
.iter()
.any(|s| s.content.as_ref().contains("# A comment"))
});
assert!(comment_line.is_some());
let comment_span = comment_line
.unwrap()
.spans
.iter()
.find(|s| s.content.as_ref().contains("# A comment"));
assert_eq!(
comment_span.unwrap().style.fg,
Some(Color::DarkGray),
"Comment should be DarkGray"
);
}
#[test]
fn untagged_code_block_with_default_r() {
let input = "```\nx <- 42\n```";
let lines_no_default = render_markdown(input, None, None);
let code_line = lines_no_default.iter().find(|l| {
let t: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
t.contains("<-")
});
assert!(code_line.is_some());
assert_eq!(code_line.unwrap().spans.len(), 1);
let lines_r = render_markdown(input, Some("r"), None);
let code_line = lines_r.iter().find(|l| {
let t: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
t.contains("<-")
});
assert!(code_line.is_some());
let code_line = code_line.unwrap();
assert!(
code_line.spans.len() >= 3,
"Default R should tokenize untagged code blocks, got {} spans",
code_line.spans.len()
);
let op_span = code_line
.spans
.iter()
.find(|s| s.content.as_ref().contains("<-"));
assert_eq!(op_span.unwrap().style.fg, Some(Color::Yellow));
}
#[test]
fn explicit_lang_overrides_default() {
let input = "```python\nprint('hello')\n```";
let lines = render_markdown(input, Some("r"), None);
let code_line = lines.iter().find(|l| {
let t: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
t.contains("print")
});
assert!(code_line.is_some());
assert_eq!(
code_line.unwrap().spans.len(),
1,
"Explicit python tag should not use R highlighting even with default_code_lang=r"
);
}
#[test]
fn empty_input() {
let lines = render_plain("");
assert!(lines.is_empty());
}
#[test]
fn paragraphs_separated_by_blank_line() {
let input = "First paragraph.\n\nSecond paragraph.";
let lines = render_plain(input);
assert_eq!(lines, vec!["First paragraph.", "", "Second paragraph."]);
}
fn render_wrapped(input: &str, width: usize) -> Vec<String> {
render_markdown(input, None, Some(width))
.iter()
.map(|line| {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect()
}
#[test]
fn wrap_long_paragraph() {
let input = "Hello world, this is a long paragraph that should wrap.";
let lines = render_wrapped(input, 20);
assert!(lines.len() > 1, "Should wrap into multiple lines");
for line in &lines {
assert!(
unicode_width::UnicodeWidthStr::width(line.as_str()) <= 20,
"Line too wide: {:?} ({})",
line,
unicode_width::UnicodeWidthStr::width(line.as_str())
);
}
}
#[test]
fn wrap_code_block_not_wrapped() {
let input =
"```\nthis is a very long code line that should not be wrapped at all ever\n```";
let lines = render_wrapped(input, 20);
let code_text: String = lines
.iter()
.find(|l| l.contains("this is"))
.cloned()
.unwrap_or_default();
assert!(code_text.len() > 20, "Code block should NOT be wrapped");
}
#[test]
fn wrap_table_cells() {
let input = "| Arg | Description |\n|---|---|\n| x | A very long description that should be wrapped within the cell |";
let lines = render_wrapped(input, 40);
assert!(
lines.len() > 3,
"Table cell should wrap: got {} lines: {:?}",
lines.len(),
lines
);
for line in &lines {
let w = unicode_width::UnicodeWidthStr::width(line.as_str());
assert!(w <= 40, "Table line too wide ({} > 40): {:?}", w, line);
}
}
#[test]
fn wrap_table_narrow_preserves_content() {
let input = "| Name | Value |\n|---|---|\n| foo | bar |";
let lines_wide = render_wrapped(input, 80);
let lines_narrow = render_wrapped(input, 30);
let all_text_wide: String = lines_wide.join("");
let all_text_narrow: String = lines_narrow.join("");
assert!(all_text_narrow.contains("foo"));
assert!(all_text_narrow.contains("bar"));
assert!(all_text_wide.contains("foo"));
assert!(all_text_wide.contains("bar"));
}
#[test]
fn wrap_prose_but_not_code_block() {
let input = "This is a long paragraph that will be wrapped at the given width.\n\n```\ncode_line_that_must_not_be_wrapped_ever()\n```";
let lines = render_wrapped(input, 30);
let code_start = lines
.iter()
.position(|l| l.contains("code_line"))
.expect("Should contain the code line");
assert!(
code_start > 2,
"Paragraph should have wrapped into multiple lines, got {}",
code_start
);
for (i, line) in lines.iter().enumerate() {
if i < code_start && !line.is_empty() {
let w = unicode_width::UnicodeWidthStr::width(line.as_str());
assert!(
w <= 30,
"Paragraph line should wrap (line {}: {} cols): {:?}",
i,
w,
line
);
}
}
let code_line = &lines[code_start];
assert!(
code_line.contains("code_line_that_must_not_be_wrapped_ever()"),
"Code block should remain intact"
);
}
#[test]
fn distribute_column_widths_empty() {
let empty: Vec<usize> = vec![];
assert_eq!(distribute_column_widths(&[], 10), empty);
}
#[test]
fn distribute_column_widths_single_column() {
assert_eq!(distribute_column_widths(&[20], 10), vec![10]);
}
#[test]
fn distribute_column_widths_all_fit() {
assert_eq!(distribute_column_widths(&[3, 3, 3], 12), vec![3, 3, 3]);
}
#[test]
fn distribute_column_widths_one_wide() {
let result = distribute_column_widths(&[2, 20, 3], 15);
assert_eq!(result[0], 2); assert_eq!(result[2], 3); assert_eq!(result[1], 10); assert_eq!(result.iter().sum::<usize>(), 15);
}
#[test]
fn distribute_column_widths_available_less_than_n() {
let result = distribute_column_widths(&[5, 10, 15], 2);
assert_eq!(result, vec![1, 1, 0]);
assert!(result.iter().sum::<usize>() <= 2);
}
#[test]
fn distribute_column_widths_available_equals_n() {
let result = distribute_column_widths(&[5, 10, 15], 3);
assert_eq!(result, vec![1, 1, 1]);
}
}