use crate::utils::decode_bin; use ansi_colours::AsRGB; use anyhow::{Context, Result, anyhow}; use comfy_table::{CellAlignment, ContentArrangement, Table, presets::UTF8_FULL}; use crossterm::style::{Color, SetForegroundColor, Stylize}; use crossterm::terminal; use fancy_regex::{Captures, Regex}; use std::collections::HashMap; use std::iter; use std::sync::LazyLock; use syntect::highlighting::{Color as SyntectColor, FontStyle, Style, Theme, ThemeItem}; use syntect::parsing::SyntaxSet; use syntect::{easy::HighlightLines, parsing::SyntaxReference}; /// Comes from const SYNTAXES: &[u8] = include_bytes!("../../assets/syntaxes.bin"); static LANG_MAPS: LazyLock> = LazyLock::new(|| { let mut m = HashMap::new(); m.insert("csharp".into(), "C#".into()); m.insert("php".into(), "PHP Source".into()); m }); static HEADING_RE: LazyLock = LazyLock::new(|| Regex::new(r"^\s*(#{1,6}) +.+").unwrap()); static BLOCKQUOTE_RE: LazyLock = LazyLock::new(|| Regex::new(r"^\s*>").unwrap()); static TASK_ITEM_RE: LazyLock = LazyLock::new(|| Regex::new(r"^\s*[-*+] \[([ xX])\] +.+").unwrap()); static BULLET_ITEM_RE: LazyLock = LazyLock::new(|| Regex::new(r"^\s*[-*+] +.+").unwrap()); static NUMBERED_ITEM_RE: LazyLock = LazyLock::new(|| Regex::new(r"^\s*\d+\. +.+").unwrap()); static HRULE_RE: LazyLock = LazyLock::new(|| Regex::new(r"^\s*(-{3,}|_{3,}|\*{3,})\s*$").unwrap()); static TABLE_SEPARATOR_RE: LazyLock = LazyLock::new(|| Regex::new(r"^\s*\|(\s*:?-+:?\s*\|)+\s*$").unwrap()); static TABLE_ROW_RE: LazyLock = LazyLock::new(|| Regex::new(r"^\s*\|.*\|\s*$").unwrap()); static INLINE_CODE_RE: LazyLock = LazyLock::new(|| Regex::new(r"`([^`\n]+)`").unwrap()); static IMAGE_RE: LazyLock = LazyLock::new(|| Regex::new(r"!\[([^\]]*)\]\(([^)]+)\)").unwrap()); static LINK_RE: LazyLock = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap()); static BOLD_AST_RE: LazyLock = LazyLock::new(|| Regex::new(r"\*\*((?:[^*\n]|\*(?!\*))+?)\*\*").unwrap()); static BOLD_US_RE: LazyLock = LazyLock::new(|| Regex::new(r"__((?:[^_\n]|_(?!_))+?)__").unwrap()); static ITALIC_AST_RE: LazyLock = LazyLock::new(|| Regex::new(r"(? = LazyLock::new(|| Regex::new(r"(? = LazyLock::new(|| Regex::new(r"~~([^~\n]+)~~").unwrap()); static CODE_PLACEHOLDER_RE: LazyLock = LazyLock::new(|| Regex::new(r"\x00C(\d+)\x00").unwrap()); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LineKind { Heading(u8), Blockquote, TaskItem(bool), BulletItem, NumberedItem, HorizontalRule, TableRow, TableSeparator, Paragraph, } fn detect_line_kind(line: &str) -> LineKind { if HRULE_RE.is_match(line).unwrap_or(false) { return LineKind::HorizontalRule; } if let Ok(Some(caps)) = HEADING_RE.captures(line) { let level = caps .get(1) .map(|m| m.as_str().len() as u8) .unwrap_or(1) .min(6); return LineKind::Heading(level); } if BLOCKQUOTE_RE.is_match(line).unwrap_or(false) { return LineKind::Blockquote; } if let Ok(Some(caps)) = TASK_ITEM_RE.captures(line) { let checked = caps.get(1).map(|m| m.as_str() != " ").unwrap_or(false); return LineKind::TaskItem(checked); } if BULLET_ITEM_RE.is_match(line).unwrap_or(false) { return LineKind::BulletItem; } if NUMBERED_ITEM_RE.is_match(line).unwrap_or(false) { return LineKind::NumberedItem; } if TABLE_SEPARATOR_RE.is_match(line).unwrap_or(false) { return LineKind::TableSeparator; } if TABLE_ROW_RE.is_match(line).unwrap_or(false) { return LineKind::TableRow; } LineKind::Paragraph } fn parse_table_row(line: &str) -> Vec { let inner = line.trim().trim_start_matches('|').trim_end_matches('|'); inner.split('|').map(|c| c.trim().to_string()).collect() } fn parse_alignments(separator_row: &str) -> Vec { parse_table_row(separator_row) .iter() .map(|c| { let trimmed = c.trim(); let starts = trimmed.starts_with(':'); let ends = trimmed.ends_with(':'); match (starts, ends) { (true, true) => CellAlignment::Center, (false, true) => CellAlignment::Right, _ => CellAlignment::Left, } }) .collect() } fn regex_replace(text: &str, re: &Regex, mut f: F) -> String where F: FnMut(&Captures) -> String, { let mut out = String::new(); let mut last_end = 0; for caps_result in re.captures_iter(text) { let Ok(caps) = caps_result else { continue }; let Some(whole) = caps.get(0) else { continue }; out.push_str(&text[last_end..whole.start()]); out.push_str(&f(&caps)); last_end = whole.end(); } out.push_str(&text[last_end..]); out } fn wrap_osc8(url: &str, visible: &str) -> String { format!("\x1b]8;;{url}\x1b\\{visible}\x1b]8;;\x1b\\") } fn style_inline_code(content: &str, styles: &MarkdownStyles) -> String { let styled = content.with(styles.inline_code_fg); match styles.inline_code_bg { Some(bg) => styled.on(bg).to_string(), None => styled.to_string(), } } fn render_markdown_line( line: &str, kind: LineKind, styles: &MarkdownStyles, wrap_width: Option, ) -> String { match kind { LineKind::Heading(level) => render_heading(line, level, styles), LineKind::Blockquote => render_blockquote(line, styles, wrap_width), LineKind::BulletItem => render_bullet(line, styles, wrap_width), LineKind::NumberedItem => render_numbered(line, styles, wrap_width), LineKind::TaskItem(checked) => render_task(line, checked, styles, wrap_width), LineKind::HorizontalRule => render_hrule(styles), LineKind::TableRow | LineKind::TableSeparator => apply_inline(line, styles), LineKind::Paragraph => apply_inline(line, styles), } } fn kind_pre_wraps(kind: LineKind) -> bool { matches!( kind, LineKind::BulletItem | LineKind::NumberedItem | LineKind::TaskItem(_) | LineKind::Blockquote ) } fn wrap_styled_content(styled: &str, effective_width: usize) -> Vec { let effective_width = effective_width.max(1); let wrapped: Vec = textwrap::wrap(styled, effective_width) .into_iter() .map(|c| c.into_owned()) .collect(); reflow_ansi_state(wrapped) } fn apply_base_style(styled: &str, fg: Color, bold: bool) -> String { let mut opener = String::new(); if fg != Color::Reset { opener.push_str(&SetForegroundColor(fg).to_string()); } if bold { opener.push_str("\x1b[1m"); } if opener.is_empty() { return styled.to_string(); } let restored = styled .replace("\x1b[0m", &format!("\x1b[0m{opener}")) .replace("\x1b[39m", &format!("\x1b[39m{opener}")); format!("{opener}{restored}\x1b[0m") } #[derive(Default, Clone, PartialEq, Eq)] struct SgrState { fg: Option, bold: bool, italic: bool, strike: bool, underline: bool, } impl SgrState { fn is_default(&self) -> bool { self.fg.is_none() && !self.bold && !self.italic && !self.strike && !self.underline } fn as_opener(&self) -> String { if self.is_default() { return String::new(); } let mut params: Vec = Vec::new(); if let Some(fg) = &self.fg { params.push(fg.clone()); } if self.bold { params.push("1".into()); } if self.italic { params.push("3".into()); } if self.underline { params.push("4".into()); } if self.strike { params.push("9".into()); } format!("\x1b[{}m", params.join(";")) } fn apply(&mut self, params: &str) { if params.is_empty() { *self = Self::default(); return; } let parts: Vec<&str> = params.split(';').collect(); let mut i = 0; while i < parts.len() { let code = parts[i]; match code { "0" | "" => *self = Self::default(), "1" => self.bold = true, "3" => self.italic = true, "4" => self.underline = true, "9" => self.strike = true, "22" => self.bold = false, "23" => self.italic = false, "24" => self.underline = false, "29" => self.strike = false, "39" => self.fg = None, "38" if i + 1 < parts.len() => match parts[i + 1] { "5" if i + 2 < parts.len() => { self.fg = Some(format!("38;5;{}", parts[i + 2])); i += 2; } "2" if i + 4 < parts.len() => { self.fg = Some(format!( "38;2;{};{};{}", parts[i + 2], parts[i + 3], parts[i + 4] )); i += 4; } _ => {} }, _ => { if let Ok(n) = code.parse::() && ((30..=37).contains(&n) || (90..=97).contains(&n)) { self.fg = Some(n.to_string()); } } } i += 1; } } } fn parse_sgr_updates(line: &str, state: &mut SgrState) { let bytes = line.as_bytes(); let mut i = 0; while i < bytes.len() { if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' { let mut j = i + 2; while j < bytes.len() && bytes[j] != b'm' { if bytes[j] == 0x1b { break; } j += 1; } if j < bytes.len() && bytes[j] == b'm' { let params = &line[i + 2..j]; state.apply(params); i = j + 1; continue; } } i += 1; } } fn reflow_ansi_state(lines: Vec) -> Vec { let mut result = Vec::with_capacity(lines.len()); let mut open_state = SgrState::default(); for line in lines { let opener = open_state.as_opener(); let mut end_state = open_state.clone(); parse_sgr_updates(&line, &mut end_state); let mut full = if opener.is_empty() { line } else { format!("{opener}{line}") }; if !end_state.is_default() { full.push_str("\x1b[0m"); } open_state = end_state; result.push(full); } result } fn split_indent(line: &str) -> (&str, &str) { let indent_len = line .char_indices() .find(|(_, c)| !c.is_whitespace()) .map(|(i, _)| i) .unwrap_or(line.len()); line.split_at(indent_len) } fn render_heading(line: &str, level: u8, styles: &MarkdownStyles) -> String { let (indent, rest) = split_indent(line); let content = rest.trim_start_matches('#').trim_start(); let inline = apply_inline(content, styles); let (color, _force_bold) = styles.heading; let body = if level == 1 { format!(" {inline} ") } else { format!("{} {inline}", "#".repeat(level as usize)) }; format!("{indent}{}", apply_base_style(&body, color, true)) } fn render_blockquote(line: &str, styles: &MarkdownStyles, wrap_width: Option) -> String { let (indent, rest) = split_indent(line); let content = rest.trim_start_matches('>').trim_start(); let prefix = "│ ".with(styles.blockquote).to_string(); let styled = apply_base_style(&apply_inline(content, styles), styles.blockquote, false); let Some(wrap_width) = wrap_width else { return format!("{indent}{prefix}{styled}"); }; let prefix_width = 2; let leading_width = indent.chars().count(); let effective_width = (wrap_width as usize).saturating_sub(leading_width + prefix_width); let wrapped = wrap_styled_content(&styled, effective_width); if wrapped.is_empty() { return format!("{indent}{prefix}"); } let mut out = String::new(); for (i, chunk) in wrapped.iter().enumerate() { if i > 0 { out.push('\n'); } out.push_str(&format!("{indent}{prefix}{chunk}")); } out } fn render_bullet(line: &str, styles: &MarkdownStyles, wrap_width: Option) -> String { let (indent, rest) = split_indent(line); let content = rest.get(2..).unwrap_or(""); let bullet = "•".with(styles.list_bullet).to_string(); let Some(wrap_width) = wrap_width else { return format!("{indent}{bullet} {}", apply_inline(content, styles)); }; let prefix_width = 2; let leading_width = indent.chars().count(); let effective_width = (wrap_width as usize).saturating_sub(leading_width + prefix_width); let styled = apply_inline(content, styles); let wrapped = wrap_styled_content(&styled, effective_width); if wrapped.is_empty() { return format!("{indent}{bullet} "); } let subseq = " ".repeat(prefix_width); let mut out = String::new(); for (i, chunk) in wrapped.iter().enumerate() { if i > 0 { out.push('\n'); } if i == 0 { out.push_str(&format!("{indent}{bullet} {chunk}")); } else { out.push_str(&format!("{indent}{subseq}{chunk}")); } } out } fn render_numbered(line: &str, styles: &MarkdownStyles, wrap_width: Option) -> String { let (indent, rest) = split_indent(line); let Some(dot_pos) = rest.find('.') else { return format!("{indent}{}", apply_inline(rest, styles)); }; let number = &rest[..dot_pos]; let after = rest[dot_pos + 1..].trim_start(); let styled_dot = ".".with(styles.list_bullet).to_string(); let Some(wrap_width) = wrap_width else { return format!( "{indent}{number}{styled_dot} {}", apply_inline(after, styles) ); }; let prefix_width = number.chars().count() + 2; let leading_width = indent.chars().count(); let effective_width = (wrap_width as usize).saturating_sub(leading_width + prefix_width); let styled = apply_inline(after, styles); let wrapped = wrap_styled_content(&styled, effective_width); if wrapped.is_empty() { return format!("{indent}{number}{styled_dot} "); } let subseq = " ".repeat(prefix_width); let mut out = String::new(); for (i, chunk) in wrapped.iter().enumerate() { if i > 0 { out.push('\n'); } if i == 0 { out.push_str(&format!("{indent}{number}{styled_dot} {chunk}")); } else { out.push_str(&format!("{indent}{subseq}{chunk}")); } } out } fn render_task( line: &str, checked: bool, styles: &MarkdownStyles, wrap_width: Option, ) -> String { let (indent, rest) = split_indent(line); let after_bullet = rest.get(2..).unwrap_or(""); let after_brackets = after_bullet.get(3..).map(str::trim_start).unwrap_or(""); let glyph = if checked { "[✓]" } else { "[ ]" }; let styled_brackets = glyph.with(styles.list_bullet).to_string(); let Some(wrap_width) = wrap_width else { return format!( "{indent}{styled_brackets} {}", apply_inline(after_brackets, styles) ); }; let prefix_width = 4; let leading_width = indent.chars().count(); let effective_width = (wrap_width as usize).saturating_sub(leading_width + prefix_width); let styled = apply_inline(after_brackets, styles); let wrapped = wrap_styled_content(&styled, effective_width); if wrapped.is_empty() { return format!("{indent}{styled_brackets} "); } let subseq = " ".repeat(prefix_width); let mut out = String::new(); for (i, chunk) in wrapped.iter().enumerate() { if i > 0 { out.push('\n'); } if i == 0 { out.push_str(&format!("{indent}{styled_brackets} {chunk}")); } else { out.push_str(&format!("{indent}{subseq}{chunk}")); } } out } fn render_hrule(styles: &MarkdownStyles) -> String { "────────".with(styles.hrule).to_string() } fn colorize_box_chars(text: &str, color: Color) -> String { let sample = "X".with(color).to_string(); let paint_idx = match sample.find('X') { Some(i) => i, None => return text.to_string(), }; let prefix = &sample[..paint_idx]; let suffix = &sample[paint_idx + 1..]; if prefix.is_empty() && suffix.is_empty() { return text.to_string(); } let mut out = String::with_capacity(text.len() + text.len() / 4); let mut in_border = false; for c in text.chars() { let is_border = matches!(c, '\u{2500}'..='\u{257F}'); if is_border && !in_border { out.push_str(prefix); in_border = true; } else if !is_border && in_border { out.push_str(suffix); in_border = false; } out.push(c); } if in_border { out.push_str(suffix); } out } fn apply_inline(text: &str, styles: &MarkdownStyles) -> String { let mut code_bank: Vec = Vec::new(); let masked = regex_replace(text, &INLINE_CODE_RE, |caps| { let content = caps.get(1).map(|m| m.as_str()).unwrap_or(""); let idx = code_bank.len(); code_bank.push(style_inline_code(content, styles)); format!("\x00C{idx}\x00") }); let with_images = regex_replace(&masked, &IMAGE_RE, |caps| { let alt = caps.get(1).map(|m| m.as_str()).unwrap_or(""); let url = caps.get(2).map(|m| m.as_str()).unwrap_or(""); let visible = format!("Image: {alt} → {url}") .with(styles.link_url) .to_string(); wrap_osc8(url, &visible) }); let with_links = regex_replace(&with_images, &LINK_RE, |caps| { let label = caps.get(1).map(|m| m.as_str()).unwrap_or(""); let url = caps.get(2).map(|m| m.as_str()).unwrap_or(""); let styled_label = label.with(styles.link_text).to_string(); let styled_url = url.with(styles.link_url).to_string(); wrap_osc8(url, &format!("{styled_label} {styled_url}")) }); let with_bold = regex_replace(&with_links, &BOLD_AST_RE, |caps| { let content = caps.get(1).map(|m| m.as_str()).unwrap_or(""); content.with(styles.bold).bold().to_string() }); let with_bold = regex_replace(&with_bold, &BOLD_US_RE, |caps| { let content = caps.get(1).map(|m| m.as_str()).unwrap_or(""); content.with(styles.bold).bold().to_string() }); let with_italic = regex_replace(&with_bold, &ITALIC_AST_RE, |caps| { let content = caps.get(1).map(|m| m.as_str()).unwrap_or(""); content.with(styles.italic).italic().to_string() }); let with_italic = regex_replace(&with_italic, &ITALIC_US_RE, |caps| { let content = caps.get(1).map(|m| m.as_str()).unwrap_or(""); content.with(styles.italic).italic().to_string() }); let with_strike = regex_replace(&with_italic, &STRIKETHROUGH_RE, |caps| { let content = caps.get(1).map(|m| m.as_str()).unwrap_or(""); content.with(styles.strikethrough).crossed_out().to_string() }); regex_replace(&with_strike, &CODE_PLACEHOLDER_RE, |caps| { let idx: usize = caps .get(1) .and_then(|m| m.as_str().parse().ok()) .unwrap_or(0); code_bank .get(idx) .cloned() .unwrap_or_else(|| caps.get(0).unwrap().as_str().to_string()) }) } enum TableState { PendingHeader(String), Active { header: Vec, alignments: Vec, rows: Vec>, }, } enum TableAction { Consumed(String), ConsumedSilent, FlushAndContinue(String), Passthrough, } pub struct MarkdownRender { options: RenderOptions, syntax_set: SyntaxSet, code_color: Option, md_syntax: SyntaxReference, code_syntax: Option, prev_line_type: LineType, wrap_width: Option, styles: MarkdownStyles, table_state: Option, } impl MarkdownRender { pub fn init(options: RenderOptions) -> Result { let syntax_set: SyntaxSet = decode_bin(SYNTAXES).with_context(|| "MarkdownRender: invalid syntaxes binary")?; let code_color = options .theme .as_ref() .map(|theme| get_code_color(theme, options.truecolor)); let md_syntax = syntax_set.find_syntax_by_extension("md").unwrap().clone(); let line_type = LineType::Normal; let wrap_width = match options.wrap.as_deref() { None => None, Some(value) => match terminal::size() { Ok((columns, _)) => { if value == "auto" { Some(columns) } else { let value = value .parse::() .map_err(|_| anyhow!("Invalid wrap value"))?; Some(columns.min(value)) } } Err(_) => None, }, }; let styles = MarkdownStyles::from_theme(options.theme.as_ref(), options.truecolor); Ok(Self { syntax_set, code_color, md_syntax, code_syntax: None, prev_line_type: line_type, wrap_width, styles, table_state: None, options, }) } pub fn render(&mut self, text: &str) -> String { text.split('\n') .filter_map(|line| self.render_line_mut(line)) .collect::>() .join("\n") } pub fn render_line(&self, line: &str) -> String { let (_, line_kind, code_syntax, is_code) = self.check_line(line); if is_code { self.highlight_code_line(line, &code_syntax) } else if self.options.raw_markdown { self.highlight_line(line, &self.md_syntax, false) } else { self.render_rich_markdown_line(line, line_kind) } } fn render_line_mut(&mut self, line: &str) -> Option { let (line_type, line_kind, code_syntax, is_code) = self.check_line(line); let table_prefix = if self.options.raw_markdown { None } else { let effective_kind = if is_code { LineKind::Paragraph } else { line_kind }; match self.handle_table_state(line, effective_kind) { TableAction::Consumed(s) => { self.prev_line_type = line_type; self.code_syntax = code_syntax; return Some(s); } TableAction::ConsumedSilent => { self.prev_line_type = line_type; self.code_syntax = code_syntax; return None; } TableAction::FlushAndContinue(s) => Some(s), TableAction::Passthrough => None, } }; let output = if is_code { self.highlight_code_line(line, &code_syntax) } else if self.options.raw_markdown { self.highlight_line(line, &self.md_syntax, false) } else { self.render_rich_markdown_line(line, line_kind) }; self.prev_line_type = line_type; self.code_syntax = code_syntax; Some(match table_prefix { Some(prefix) => format!("{prefix}\n{output}"), None => output, }) } fn render_as_paragraph(&self, line: &str) -> String { self.render_rich_markdown_line(line, LineKind::Paragraph) } fn handle_table_state(&mut self, line: &str, kind: LineKind) -> TableAction { match (self.table_state.take(), kind) { (None, LineKind::TableRow) => { self.table_state = Some(TableState::PendingHeader(line.to_string())); TableAction::ConsumedSilent } (None, LineKind::TableSeparator) => TableAction::Passthrough, (None, _) => TableAction::Passthrough, (Some(TableState::PendingHeader(header_line)), LineKind::TableSeparator) => { let header = parse_table_row(&header_line); let alignments = parse_alignments(line); self.table_state = Some(TableState::Active { header, alignments, rows: Vec::new(), }); TableAction::ConsumedSilent } (Some(TableState::PendingHeader(header_line)), LineKind::TableRow) => { let a = self.render_as_paragraph(&header_line); let b = self.render_as_paragraph(line); TableAction::Consumed(format!("{a}\n{b}")) } (Some(TableState::PendingHeader(header_line)), _) => { let flushed = self.render_as_paragraph(&header_line); TableAction::FlushAndContinue(flushed) } ( Some(TableState::Active { header, alignments, mut rows, }), LineKind::TableRow, ) => { rows.push(parse_table_row(line)); self.table_state = Some(TableState::Active { header, alignments, rows, }); TableAction::ConsumedSilent } ( Some(TableState::Active { header, alignments, rows, }), _, ) => { let rendered = self.render_table(header, alignments, rows); TableAction::FlushAndContinue(rendered) } } } pub fn finalize(&mut self) -> String { match self.table_state.take() { None => String::new(), Some(TableState::PendingHeader(line)) => self.render_as_paragraph(&line), Some(TableState::Active { header, alignments, rows, }) => self.render_table(header, alignments, rows), } } fn render_rich_markdown_line(&self, line: &str, kind: LineKind) -> String { let styled = render_markdown_line(line, kind, &self.styles, self.wrap_width); if kind_pre_wraps(kind) { styled } else { self.wrap_line(styled, false) } } fn render_table( &self, header: Vec, alignments: Vec, rows: Vec>, ) -> String { let mut table = Table::new(); table.load_preset(UTF8_FULL); table.set_content_arrangement(ContentArrangement::Dynamic); if let Some(width) = self.wrap_width { table.set_width(width); } let (heading_color, _) = self.styles.heading; let styled_header: Vec = header .iter() .map(|c| apply_base_style(&apply_inline(c, &self.styles), heading_color, true)) .collect(); table.set_header(styled_header); for (i, align) in alignments.iter().enumerate() { if let Some(col) = table.column_mut(i) { col.set_cell_alignment(*align); } } for row in rows { let styled_row: Vec = row.iter().map(|c| apply_inline(c, &self.styles)).collect(); table.add_row(styled_row); } colorize_box_chars(&table.to_string(), self.styles.table_border) } fn check_line(&self, line: &str) -> (LineType, LineKind, Option, bool) { let mut line_type = self.prev_line_type; let mut code_syntax = self.code_syntax.clone(); let mut is_code = false; if let Some(lang) = detect_code_block(line) { match line_type { LineType::Normal | LineType::CodeEnd => { line_type = LineType::CodeBegin; code_syntax = if lang.is_empty() { None } else { self.find_syntax(&lang).cloned() }; } LineType::CodeBegin | LineType::CodeInner => { line_type = LineType::CodeEnd; code_syntax = None; } } } else { match line_type { LineType::Normal => {} LineType::CodeEnd => { line_type = LineType::Normal; } LineType::CodeBegin => { if code_syntax.is_none() && let Some(syntax) = self.syntax_set.find_syntax_by_first_line(line) { code_syntax = Some(syntax.clone()); } line_type = LineType::CodeInner; is_code = true; } LineType::CodeInner => { is_code = true; } } } let line_kind = if is_code { LineKind::Paragraph } else { detect_line_kind(line) }; (line_type, line_kind, code_syntax, is_code) } fn highlight_line(&self, line: &str, syntax: &SyntaxReference, is_code: bool) -> String { let ws: String = line.chars().take_while(|c| c.is_whitespace()).collect(); let trimmed_line: &str = &line[ws.len()..]; let mut line_highlighted = None; if let Some(theme) = &self.options.theme { let mut highlighter = HighlightLines::new(syntax, theme); if let Ok(ranges) = highlighter.highlight_line(trimmed_line, &self.syntax_set) { line_highlighted = Some(format!( "{ws}{}", as_terminal_escaped(&ranges, self.options.truecolor) )) } } let line = line_highlighted.unwrap_or_else(|| line.into()); self.wrap_line(line, is_code) } fn highlight_code_line(&self, line: &str, code_syntax: &Option) -> String { if let Some(syntax) = code_syntax { self.highlight_line(line, syntax, true) } else { let line = match self.code_color { Some(color) => line.with(color).to_string(), None => line.to_string(), }; self.wrap_line(line, true) } } fn wrap_line(&self, line: String, is_code: bool) -> String { if let Some(width) = self.wrap_width { if is_code && !self.options.wrap_code { return line; } wrap(&line, width as usize) } else { line } } fn find_syntax(&self, lang: &str) -> Option<&SyntaxReference> { if let Some(new_lang) = LANG_MAPS.get(&lang.to_ascii_lowercase()) { self.syntax_set.find_syntax_by_name(new_lang) } else { self.syntax_set .find_syntax_by_token(lang) .or_else(|| self.syntax_set.find_syntax_by_extension(lang)) } } } fn wrap(text: &str, width: usize) -> String { let indent: usize = text.chars().take_while(|c| *c == ' ').count(); let wrap_options = textwrap::Options::new(width) .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit) .initial_indent(&text[0..indent]); textwrap::wrap(&text[indent..], wrap_options).join("\n") } #[derive(Debug, Clone, Default)] pub struct RenderOptions { pub theme: Option, pub wrap: Option, pub wrap_code: bool, pub raw_markdown: bool, pub truecolor: bool, } impl RenderOptions { pub(crate) fn new( theme: Option, wrap: Option, wrap_code: bool, raw_markdown: bool, truecolor: bool, ) -> Self { Self { theme, wrap, wrap_code, raw_markdown, truecolor, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LineType { Normal, CodeBegin, CodeInner, CodeEnd, } fn as_terminal_escaped(ranges: &[(Style, &str)], truecolor: bool) -> String { let mut output = String::new(); for (style, text) in ranges { let fg = blend_fg_color(style.foreground, style.background); let mut text = text.with(convert_color(fg, truecolor)); if style.font_style.contains(FontStyle::BOLD) { text = text.bold(); } if style.font_style.contains(FontStyle::UNDERLINE) { text = text.underlined(); } output.push_str(&text.to_string()); } output } fn convert_color(c: SyntectColor, truecolor: bool) -> Color { if truecolor { Color::Rgb { r: c.r, g: c.g, b: c.b, } } else { let value = (c.r, c.g, c.b).to_ansi256(); // lower contrast let value = match value { 7 | 15 | 231 | 252..=255 => 252, _ => value, }; Color::AnsiValue(value) } } fn blend_fg_color(fg: SyntectColor, bg: SyntectColor) -> SyntectColor { if fg.a == 0xff { return fg; } let ratio = u32::from(fg.a); let r = (u32::from(fg.r) * ratio + u32::from(bg.r) * (255 - ratio)) / 255; let g = (u32::from(fg.g) * ratio + u32::from(bg.g) * (255 - ratio)) / 255; let b = (u32::from(fg.b) * ratio + u32::from(bg.b) * (255 - ratio)) / 255; SyntectColor { r: u8::try_from(r).unwrap_or(u8::MAX), g: u8::try_from(g).unwrap_or(u8::MAX), b: u8::try_from(b).unwrap_or(u8::MAX), a: 255, } } fn detect_code_block(line: &str) -> Option { let line = line.trim_start(); if !line.starts_with("```") { return None; } let lang = line .chars() .skip(3) .take_while(|v| !v.is_whitespace()) .collect(); Some(lang) } fn get_code_color(theme: &Theme, truecolor: bool) -> Color { let scope = theme.scopes.iter().find(|v| { v.scope .selectors .iter() .any(|v| v.path.scopes.iter().any(|v| v.to_string() == "string")) }); scope .and_then(|v| v.style.foreground) .map_or_else(|| Color::Yellow, |c| convert_color(c, truecolor)) } #[derive(Debug, Clone, Copy, Default)] struct ResolvedStyle { fg: Option, bg: Option, font_style: FontStyle, } fn find_theme_scope<'a>(theme: &'a Theme, name: &str) -> Option<&'a ThemeItem> { theme.scopes.iter().find(|item| { item.scope .selectors .iter() .any(|sel| sel.path.scopes.iter().any(|s| s.to_string() == name)) }) } fn resolve_scope_style( theme: &Theme, primary: &str, fallbacks: &[&str], truecolor: bool, ) -> ResolvedStyle { for scope_name in iter::once(primary).chain(fallbacks.iter().copied()) { let Some(item) = find_theme_scope(theme, scope_name) else { continue; }; let resolved = ResolvedStyle { fg: item.style.foreground.map(|c| convert_color(c, truecolor)), bg: item.style.background.map(|c| convert_color(c, truecolor)), font_style: item.style.font_style.unwrap_or_default(), }; if resolved.fg.is_some() || resolved.bg.is_some() || !resolved.font_style.is_empty() { return resolved; } } ResolvedStyle::default() } #[derive(Debug, Clone)] pub struct MarkdownStyles { heading: (Color, bool), bold: Color, italic: Color, inline_code_fg: Color, inline_code_bg: Option, blockquote: Color, list_bullet: Color, link_text: Color, link_url: Color, strikethrough: Color, hrule: Color, table_border: Color, } impl MarkdownStyles { fn from_theme(theme: Option<&Theme>, truecolor: bool) -> Self { let Some(theme) = theme else { return Self::none(); }; let heading = resolve_scope_style( theme, "markup.heading", &["markup.bold", "entity.name.section"], truecolor, ); let bold = resolve_scope_style( theme, "markup.bold", &["entity.other.attribute-name"], truecolor, ); let italic = resolve_scope_style(theme, "markup.italic", &["comment"], truecolor); let inline_code = resolve_scope_style( theme, "markup.raw.inline", &["markup.raw", "string"], truecolor, ); let blockquote = resolve_scope_style(theme, "markup.quote", &["comment", "string"], truecolor); let list_bullet = resolve_scope_style( theme, "markup.list", &["punctuation.definition.list", "keyword"], truecolor, ); let link_text = resolve_scope_style( theme, "markup.link", &["string.other.link", "entity.name.tag"], truecolor, ); let link_url = resolve_scope_style( theme, "markup.underline.link", &["string.other.link", "constant"], truecolor, ); let strikethrough = resolve_scope_style(theme, "markup.deleted", &["invalid"], truecolor); let hrule = resolve_scope_style(theme, "comment", &["punctuation"], truecolor); let table_border = resolve_scope_style( theme, "punctuation.definition.table.markdown", &["punctuation", "meta.separator"], truecolor, ); Self { heading: ( heading.fg.unwrap_or(Color::Yellow), heading.font_style.contains(FontStyle::BOLD), ), bold: bold.fg.unwrap_or(Color::Reset), italic: italic.fg.unwrap_or(Color::Reset), inline_code_fg: inline_code.fg.unwrap_or(Color::Yellow), inline_code_bg: inline_code.bg, blockquote: blockquote.fg.unwrap_or(Color::DarkGrey), list_bullet: list_bullet.fg.unwrap_or(Color::Reset), link_text: link_text.fg.unwrap_or(Color::Cyan), link_url: link_url.fg.unwrap_or(Color::Blue), strikethrough: strikethrough.fg.unwrap_or(Color::DarkGrey), hrule: hrule.fg.unwrap_or(Color::DarkGrey), table_border: table_border.fg.unwrap_or(Color::DarkGrey), } } fn none() -> Self { Self { heading: (Color::Reset, false), bold: Color::Reset, italic: Color::Reset, inline_code_fg: Color::Reset, inline_code_bg: None, blockquote: Color::Reset, list_bullet: Color::Reset, link_text: Color::Reset, link_url: Color::Reset, strikethrough: Color::Reset, hrule: Color::Reset, table_border: Color::Reset, } } } #[cfg(test)] mod tests { use super::*; const TEXT: &str = r#" To unzip a file in Rust, you can use the `zip` crate. Here's an example code that shows how to unzip a file: ```rust use std::fs::File; fn unzip_file(path: &str, output_dir: &str) -> Result<(), Box> { todo!() } ``` "#; const TEXT_NO_WRAP_CODE: &str = r#" To unzip a file in Rust, you can use the `zip` crate. Here's an example code that shows how to unzip a file: ```rust use std::fs::File; fn unzip_file(path: &str, output_dir: &str) -> Result<(), Box> { todo!() } ``` "#; const TEXT_WRAP_ALL: &str = r#" To unzip a file in Rust, you can use the `zip` crate. Here's an example code that shows how to unzip a file: ```rust use std::fs::File; fn unzip_file(path: &str, output_dir: &str) -> Result<(), Box> { todo!() } ``` "#; #[test] fn test_render() { let options = RenderOptions::default(); let render = MarkdownRender::init(options).unwrap(); assert!(render.find_syntax("csharp").is_some()); } #[test] fn no_theme() { let options = RenderOptions { raw_markdown: true, ..Default::default() }; let mut render = MarkdownRender::init(options).unwrap(); let output = render.render(TEXT); assert_eq!(TEXT, output); } #[test] fn no_wrap_code() { let options = RenderOptions { raw_markdown: true, ..Default::default() }; let mut render = MarkdownRender::init(options).unwrap(); render.wrap_width = Some(80); let output = render.render(TEXT); assert_eq!(TEXT_NO_WRAP_CODE, output); } #[test] fn wrap_all() { let options = RenderOptions { wrap_code: true, raw_markdown: true, ..Default::default() }; let mut render = MarkdownRender::init(options).unwrap(); render.wrap_width = Some(80); let output = render.render(TEXT); assert_eq!(TEXT_WRAP_ALL, output); } #[test] fn test_detect_code_block() { assert_eq!(detect_code_block("```rust"), Some("rust".into())); assert_eq!(detect_code_block("```c++"), Some("c++".into())); assert_eq!(detect_code_block(" ```rust"), Some("rust".into())); assert_eq!(detect_code_block("```"), Some("".into())); assert_eq!(detect_code_block("``rust"), None); } use std::str::FromStr; use syntect::highlighting::{ScopeSelectors, StyleModifier, ThemeItem}; const DARK_THEME_BYTES: &[u8] = include_bytes!("../../assets/monokai-extended.theme.bin"); fn load_dark_theme() -> Theme { decode_bin(DARK_THEME_BYTES).expect("built-in dark theme should decode") } fn syntect_rgb(r: u8, g: u8, b: u8) -> SyntectColor { SyntectColor { r, g, b, a: 255 } } fn theme_item(scope: &str, fg: SyntectColor, font_style: Option) -> ThemeItem { ThemeItem { scope: ScopeSelectors::from_str(scope).expect("valid scope selector"), style: StyleModifier { foreground: Some(fg), background: None, font_style, }, } } fn minimal_root_scope_theme() -> Theme { let mut theme = Theme::default(); theme .scopes .push(theme_item("string", syntect_rgb(0xff, 0xdd, 0x00), None)); theme .scopes .push(theme_item("comment", syntect_rgb(0x88, 0x88, 0x88), None)); theme .scopes .push(theme_item("keyword", syntect_rgb(0xaa, 0x00, 0xff), None)); theme .scopes .push(theme_item("constant", syntect_rgb(0x00, 0xcc, 0xff), None)); theme.scopes.push(theme_item( "entity.name.tag", syntect_rgb(0x11, 0x22, 0x33), None, )); theme.scopes.push(theme_item( "entity.other.attribute-name", syntect_rgb(0x44, 0x55, 0x66), Some(FontStyle::BOLD), )); theme.scopes.push(theme_item( "entity.name.section", syntect_rgb(0xde, 0xad, 0xbe), Some(FontStyle::BOLD), )); theme .scopes .push(theme_item("invalid", syntect_rgb(0xff, 0x00, 0x00), None)); theme.scopes.push(theme_item( "punctuation", syntect_rgb(0x77, 0x77, 0x77), None, )); theme } fn rgb(r: u8, g: u8, b: u8) -> Color { Color::Rgb { r, g, b } } #[test] fn resolve_scope_style_uses_primary() { let mut theme = Theme::default(); theme.scopes.push(theme_item( "markup.bold", syntect_rgb(0xaa, 0xbb, 0xcc), Some(FontStyle::BOLD), )); let resolved = resolve_scope_style(&theme, "markup.bold", &["fallback"], true); assert_eq!(resolved.fg, Some(rgb(0xaa, 0xbb, 0xcc))); assert!(resolved.font_style.contains(FontStyle::BOLD)); } #[test] fn resolve_scope_style_falls_back_when_primary_missing() { let mut theme = Theme::default(); theme .scopes .push(theme_item("comment", syntect_rgb(0x33, 0x44, 0x55), None)); let resolved = resolve_scope_style(&theme, "markup.italic", &["nope", "comment"], true); assert_eq!(resolved.fg, Some(rgb(0x33, 0x44, 0x55))); } #[test] fn resolve_scope_style_returns_default_when_nothing_matches() { let theme = Theme::default(); let resolved = resolve_scope_style(&theme, "markup.italic", &["comment"], true); assert!(resolved.fg.is_none()); assert!(resolved.bg.is_none()); assert!(resolved.font_style.is_empty()); } #[test] fn markdown_styles_none_when_theme_absent() { let styles = MarkdownStyles::from_theme(None, true); assert_eq!(styles.heading, (Color::Reset, false)); assert_eq!(styles.bold, Color::Reset); assert_eq!(styles.italic, Color::Reset); assert_eq!(styles.inline_code_fg, Color::Reset); assert_eq!(styles.inline_code_bg, None); assert_eq!(styles.blockquote, Color::Reset); assert_eq!(styles.list_bullet, Color::Reset); assert_eq!(styles.link_text, Color::Reset); assert_eq!(styles.link_url, Color::Reset); assert_eq!(styles.strikethrough, Color::Reset); assert_eq!(styles.hrule, Color::Reset); assert_eq!(styles.table_border, Color::Reset); } #[test] fn markdown_styles_resolve_with_builtin_dark_theme() { let theme = load_dark_theme(); let styles = MarkdownStyles::from_theme(Some(&theme), true); assert_ne!(styles.heading.0, Color::Reset); assert_ne!(styles.bold, Color::Reset); assert_ne!(styles.italic, Color::Reset); assert_ne!(styles.inline_code_fg, Color::Reset); assert_ne!(styles.blockquote, Color::Reset); assert_ne!(styles.list_bullet, Color::Reset); assert_ne!(styles.link_text, Color::Reset); assert_ne!(styles.link_url, Color::Reset); assert_ne!(styles.strikethrough, Color::Reset); assert_ne!(styles.hrule, Color::Reset); assert_ne!(styles.table_border, Color::Reset); } #[test] fn detect_line_kind_heading_levels() { assert_eq!(detect_line_kind("# H1"), LineKind::Heading(1)); assert_eq!(detect_line_kind("## H2"), LineKind::Heading(2)); assert_eq!(detect_line_kind("### H3"), LineKind::Heading(3)); assert_eq!(detect_line_kind("#### H4"), LineKind::Heading(4)); assert_eq!(detect_line_kind("##### H5"), LineKind::Heading(5)); assert_eq!(detect_line_kind("###### H6"), LineKind::Heading(6)); assert_eq!(detect_line_kind(" ## Indented"), LineKind::Heading(2)); } #[test] fn detect_line_kind_heading_requires_space_after_hashes() { assert_eq!(detect_line_kind("##notheading"), LineKind::Paragraph); assert_eq!(detect_line_kind("#"), LineKind::Paragraph); } #[test] fn detect_line_kind_blockquote() { assert_eq!(detect_line_kind("> quoted"), LineKind::Blockquote); assert_eq!(detect_line_kind(">no space"), LineKind::Blockquote); assert_eq!(detect_line_kind(" > indented"), LineKind::Blockquote); assert_eq!(detect_line_kind(">"), LineKind::Blockquote); } #[test] fn detect_line_kind_task_item() { assert_eq!(detect_line_kind("- [ ] todo"), LineKind::TaskItem(false)); assert_eq!(detect_line_kind("- [x] done"), LineKind::TaskItem(true)); assert_eq!(detect_line_kind("- [X] done"), LineKind::TaskItem(true)); assert_eq!(detect_line_kind("* [ ] todo"), LineKind::TaskItem(false)); assert_eq!( detect_line_kind(" - [x] indented"), LineKind::TaskItem(true) ); } #[test] fn detect_line_kind_bullet_item() { assert_eq!(detect_line_kind("- item"), LineKind::BulletItem); assert_eq!(detect_line_kind("* item"), LineKind::BulletItem); assert_eq!(detect_line_kind("+ item"), LineKind::BulletItem); assert_eq!(detect_line_kind(" - nested"), LineKind::BulletItem); assert_eq!(detect_line_kind("-nospace"), LineKind::Paragraph); } #[test] fn detect_line_kind_numbered_item() { assert_eq!(detect_line_kind("1. first"), LineKind::NumberedItem); assert_eq!(detect_line_kind("42. answer"), LineKind::NumberedItem); assert_eq!(detect_line_kind(" 3. nested"), LineKind::NumberedItem); assert_eq!(detect_line_kind("1.nospace"), LineKind::Paragraph); } #[test] fn detect_line_kind_horizontal_rule() { assert_eq!(detect_line_kind("---"), LineKind::HorizontalRule); assert_eq!(detect_line_kind("___"), LineKind::HorizontalRule); assert_eq!(detect_line_kind("***"), LineKind::HorizontalRule); assert_eq!(detect_line_kind("--------"), LineKind::HorizontalRule); assert_eq!(detect_line_kind(" ---"), LineKind::HorizontalRule); assert_eq!(detect_line_kind("--- "), LineKind::HorizontalRule); assert_eq!(detect_line_kind("--"), LineKind::Paragraph); } #[test] fn detect_line_kind_paragraph_default() { assert_eq!(detect_line_kind("just text"), LineKind::Paragraph); assert_eq!(detect_line_kind(""), LineKind::Paragraph); assert_eq!(detect_line_kind(" "), LineKind::Paragraph); } #[test] fn detect_line_kind_table_row() { assert_eq!(detect_line_kind("| a | b |"), LineKind::TableRow); assert_eq!(detect_line_kind("|a|b|c|"), LineKind::TableRow); assert_eq!(detect_line_kind(" | a | b | "), LineKind::TableRow); assert_eq!(detect_line_kind("| | |"), LineKind::TableRow); } #[test] fn detect_line_kind_table_separator() { assert_eq!(detect_line_kind("|---|---|"), LineKind::TableSeparator); assert_eq!(detect_line_kind("| --- | --- |"), LineKind::TableSeparator); assert_eq!( detect_line_kind("|:---|---:|:---:|---|"), LineKind::TableSeparator, ); assert_eq!(detect_line_kind("|:--|--:|:-:|"), LineKind::TableSeparator); assert_eq!(detect_line_kind(" |---|---| "), LineKind::TableSeparator); } #[test] fn detect_line_kind_non_table_pipe_line_stays_paragraph() { assert_eq!(detect_line_kind("use `a | b` for or"), LineKind::Paragraph,); assert_eq!(detect_line_kind("| trailing"), LineKind::Paragraph); assert_eq!(detect_line_kind("no closer |"), LineKind::Paragraph); } #[test] fn detect_line_kind_prefers_separator_over_row() { assert_eq!(detect_line_kind("|---|---|"), LineKind::TableSeparator); assert_ne!(detect_line_kind("|---|---|"), LineKind::TableRow); } #[test] fn parse_table_row_splits_cells() { assert_eq!(parse_table_row("| a | b | c |"), vec!["a", "b", "c"]); assert_eq!(parse_table_row("|a|b|c|"), vec!["a", "b", "c"]); } #[test] fn parse_table_row_handles_empty_cells() { assert_eq!(parse_table_row("| a | | c |"), vec!["a", "", "c"]); assert_eq!(parse_table_row("| | | |"), vec!["", "", ""]); } #[test] fn parse_table_row_trims_whitespace() { assert_eq!( parse_table_row(" | foo | bar | "), vec!["foo", "bar"], ); } #[test] fn parse_alignments_reads_colons() { assert_eq!( parse_alignments("|:---|---:|:---:|---|"), vec![ CellAlignment::Left, CellAlignment::Right, CellAlignment::Center, CellAlignment::Left, ], ); } #[test] fn parse_alignments_short_dashes() { assert_eq!( parse_alignments("|:--|--:|:-:|"), vec![ CellAlignment::Left, CellAlignment::Right, CellAlignment::Center, ], ); } #[test] fn parse_alignments_defaults_to_left() { assert_eq!( parse_alignments("|---|---|---|"), vec![ CellAlignment::Left, CellAlignment::Left, CellAlignment::Left, ], ); } #[test] fn colorize_box_chars_wraps_border_runs() { let input = "┌─┐\nabc\n└─┘"; let output = colorize_box_chars(input, Color::Red); assert!( output.starts_with("\x1b["), "border run starts with SGR: {output:?}" ); assert!(output.contains("abc"), "non-border content preserved"); assert!(output.contains("┌")); assert!(output.contains("└")); } #[test] fn colorize_box_chars_leaves_reset_color_untouched() { let output = colorize_box_chars("no borders here", Color::Red); assert_eq!(output, "no borders here"); } #[test] fn render_table_renders_headers_rows_and_borders() { let options = RenderOptions::default(); let render = MarkdownRender::init(options).unwrap(); let header = vec!["A".into(), "B".into(), "C".into()]; let alignments = vec![ CellAlignment::Left, CellAlignment::Left, CellAlignment::Left, ]; let rows = vec![ vec!["1".into(), "2".into(), "3".into()], vec!["4".into(), "5".into(), "6".into()], ]; let output = render.render_table(header, alignments, rows); for expected in ["A", "B", "C", "1", "2", "3", "4", "5", "6"] { assert!( output.contains(expected), "cell {expected:?} in output: {output:?}" ); } assert!( output.chars().any(|c| matches!(c, '\u{2500}'..='\u{257F}')), "table has box-drawing chars: {output:?}", ); } #[test] fn render_table_header_uses_bold() { let options = RenderOptions::default(); let render = MarkdownRender::init(options).unwrap(); let header = vec!["Header".into()]; let alignments = vec![CellAlignment::Left]; let output = render.render_table(header, alignments, vec![]); assert!(output.contains("\x1b[1m"), "bold SGR present: {output:?}"); assert!(output.contains("Header")); } #[test] fn render_table_applies_inline_markdown_to_cells() { let options = RenderOptions::default(); let render = MarkdownRender::init(options).unwrap(); let header = vec!["H".into()]; let alignments = vec![CellAlignment::Left]; let rows = vec![vec!["**bold**".into()]]; let output = render.render_table(header, alignments, rows); assert!( !output.contains("**bold**"), "asterisks stripped from cell: {output:?}", ); assert!(output.contains("bold")); assert!(output.contains("\x1b[1m"), "bold SGR present: {output:?}"); } #[test] fn render_table_respects_alignment_specifiers() { let options = RenderOptions::default(); let render = MarkdownRender::init(options).unwrap(); let header = vec!["L".into(), "R".into(), "C".into()]; let alignments = vec![ CellAlignment::Left, CellAlignment::Right, CellAlignment::Center, ]; let rows = vec![vec!["a".into(), "b".into(), "c".into()]]; let output = render.render_table(header, alignments, rows); for expected in ["L", "R", "C", "a", "b", "c"] { assert!(output.contains(expected), "cell {expected:?} present"); } } #[test] fn render_table_handles_wide_chars_and_emoji() { let options = RenderOptions::default(); let render = MarkdownRender::init(options).unwrap(); let header = vec!["Name".into()]; let alignments = vec![CellAlignment::Left]; let rows = vec![vec!["🎉".into()], vec!["日本".into()]]; let output = render.render_table(header, alignments, rows); assert!(output.contains("🎉")); assert!(output.contains("日本")); assert!(output.lines().count() > 4, "multi-line output: {output:?}"); } #[test] fn render_table_borders_wrapped_in_border_color() { let options = RenderOptions::default(); let render = MarkdownRender::init(options).unwrap(); let header = vec!["a".into()]; let alignments = vec![CellAlignment::Left]; let output = render.render_table(header, alignments, vec![vec!["b".into()]]); assert!( output.starts_with("\x1b["), "output starts with border color SGR: {output:?}", ); } fn strip_ansi(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut chars = text.chars(); while let Some(c) = chars.next() { if c != '\u{1b}' { out.push(c); continue; } if chars.next() == Some('[') { for c in chars.by_ref() { if ('\u{40}'..='\u{7e}').contains(&c) { break; } } } } out } #[test] fn strip_ansi_removes_sgr_and_keeps_text() { assert_eq!(strip_ansi("\x1b[1mbold\x1b[0m"), "bold"); assert_eq!(strip_ansi("\x1b[38;5;120mx\x1b[39m"), "x"); assert_eq!(strip_ansi("plain"), "plain"); } #[test] fn render_table_pads_columns_by_display_width() { use unicode_width::UnicodeWidthStr; const WRAP_WIDTH: u16 = 80; let options = RenderOptions::default(); let mut render = MarkdownRender::init(options).unwrap(); render.wrap_width = Some(WRAP_WIDTH); let header = vec![ "**Setting**".into(), "*Default*".into(), "`Description`".into(), ]; let alignments = vec![ CellAlignment::Left, CellAlignment::Right, CellAlignment::Center, ]; let rows = vec![ vec![ "**temperature**".into(), "`0.7`".into(), "Controls how *random* the sampled reply is allowed to be".into(), ], vec![ "**top_p**".into(), "`1.0`".into(), "Nucleus sampling cutoff, applied **after** temperature".into(), ], ]; let output = render.render_table(header, alignments, rows); assert!( output.contains('\u{1b}'), "fixture must actually contain ANSI escapes: {output:?}", ); let widths: Vec = output .lines() .map(|line| strip_ansi(line).width()) .collect(); assert!(!widths.is_empty(), "table rendered no lines"); for (index, width) in widths.iter().enumerate() { assert_eq!( *width, WRAP_WIDTH as usize, "line {index} display width; all widths were {widths:?} in output:\n{output}", ); } } #[test] fn state_machine_renders_full_table_and_flushes_on_paragraph() { let options = RenderOptions::default(); let mut render = MarkdownRender::init(options).unwrap(); let text = "| A | B |\n|---|---|\n| 1 | 2 |\n\nafter\n"; let output = render.render(text); for cell in ["A", "B", "1", "2"] { assert!(output.contains(cell), "cell {cell:?} rendered: {output:?}"); } assert!( output.chars().any(|c| matches!(c, '\u{2500}'..='\u{257F}')), "output has box-drawing chars: {output:?}", ); assert!(output.contains("after"), "trailing paragraph preserved"); } #[test] fn state_machine_defers_output_until_flush() { let options = RenderOptions::default(); let mut render = MarkdownRender::init(options).unwrap(); let header = render.render_line_mut("| A | B |"); assert!(header.is_none(), "header row silently consumed"); let sep = render.render_line_mut("|---|---|"); assert!(sep.is_none(), "separator silently consumed"); let data = render.render_line_mut("| 1 | 2 |"); assert!(data.is_none(), "data row silently consumed"); } #[test] fn finalize_emits_pending_active_table() { let options = RenderOptions::default(); let mut render = MarkdownRender::init(options).unwrap(); render.render_line_mut("| A | B |"); render.render_line_mut("|---|---|"); render.render_line_mut("| 1 | 2 |"); let tail = render.finalize(); assert!(tail.contains("A")); assert!(tail.contains("1")); assert!(tail.contains("2")); assert!(tail.chars().any(|c| matches!(c, '\u{2500}'..='\u{257F}'))); } #[test] fn finalize_flushes_pending_header_as_paragraph() { let options = RenderOptions::default(); let mut render = MarkdownRender::init(options).unwrap(); render.render_line_mut("| A | B |"); let tail = render.finalize(); assert!(tail.contains("A")); assert!(tail.contains("B")); assert!(tail.contains("|"), "raw pipes preserved: {tail:?}"); } #[test] fn finalize_is_empty_when_no_pending_table() { let options = RenderOptions::default(); let mut render = MarkdownRender::init(options).unwrap(); render.render_line_mut("plain text"); assert!(render.finalize().is_empty()); } #[test] fn pipe_row_without_separator_flushes_as_paragraphs() { let options = RenderOptions::default(); let mut render = MarkdownRender::init(options).unwrap(); let text = "| A | B |\n| C | D |\nafter\n"; let output = render.render(text); assert!( output.contains("| A | B |"), "raw pipes preserved for first: {output:?}", ); assert!( output.contains("| C | D |"), "raw pipes preserved for second: {output:?}", ); assert!(output.contains("after")); } #[test] fn multiple_tables_in_one_input() { let options = RenderOptions::default(); let mut render = MarkdownRender::init(options).unwrap(); let text = "| A |\n|---|\n| 1 |\n\n| B |\n|---|\n| 2 |\n"; let output = render.render(text); let tail = render.finalize(); let combined = format!("{output}{tail}"); for cell in ["A", "B", "1", "2"] { assert!(combined.contains(cell), "cell {cell:?}: {combined:?}"); } } #[test] fn render_line_immutable_does_not_mutate_table_state() { let options = RenderOptions::default(); let render = MarkdownRender::init(options).unwrap(); let _ = render.render_line("| foo | ba"); assert!( render.table_state.is_none(), "render_line is immutable; state stays clean", ); } #[test] fn raw_markdown_mode_bypasses_table_rendering() { let options = RenderOptions { raw_markdown: true, ..Default::default() }; let mut render = MarkdownRender::init(options).unwrap(); let text = "| A | B |\n|---|---|\n| 1 | 2 |\n"; let output = render.render(text); assert!( output.contains("| A | B |"), "raw pipes preserved: {output:?}", ); assert!(render.table_state.is_none(), "no state entered in raw mode",); } #[test] fn bullet_wraps_with_two_space_hanging_indent() { let styles = test_styles(); let line = "- text that is long enough to wrap onto continuation lines"; let output = render_markdown_line(line, LineKind::BulletItem, &styles, Some(20)); assert!(output.contains('\n'), "wrapped output: {output:?}"); let lines: Vec<&str> = output.split('\n').collect(); assert!(lines.len() >= 2); for cont in &lines[1..] { assert!( cont.starts_with(" ") && !cont.starts_with(" •"), "continuation has 2-space indent (no bullet): {cont:?}", ); } } #[test] fn numbered_wraps_with_digit_width_hanging_indent() { let styles = test_styles(); let line = "42. text that is long enough to wrap onto continuation lines"; let output = render_markdown_line(line, LineKind::NumberedItem, &styles, Some(22)); assert!(output.contains('\n'), "wrapped output: {output:?}"); let lines: Vec<&str> = output.split('\n').collect(); for cont in &lines[1..] { assert!( cont.starts_with(" "), "4-space indent for `42. `: {cont:?}" ); } } #[test] fn numbered_wraps_with_three_digit_hanging_indent() { let styles = test_styles(); let line = "100. text that is long enough to wrap onto continuation lines"; let output = render_markdown_line(line, LineKind::NumberedItem, &styles, Some(22)); assert!(output.contains('\n')); let lines: Vec<&str> = output.split('\n').collect(); for cont in &lines[1..] { assert!( cont.starts_with(" "), "5-space indent for `100. `: {cont:?}", ); } } #[test] fn task_wraps_with_four_space_hanging_indent() { let styles = test_styles(); let line = "- [ ] task text that is long enough to wrap around"; let output = render_markdown_line(line, LineKind::TaskItem(false), &styles, Some(22)); assert!(output.contains('\n'), "wrapped output: {output:?}"); let lines: Vec<&str> = output.split('\n').collect(); for cont in &lines[1..] { assert!( cont.starts_with(" "), "4-space indent for `[ ] `: {cont:?}" ); } } #[test] fn blockquote_wraps_with_pipe_prefix_on_every_line() { let styles = test_styles(); let line = "> quoted text that is long enough to wrap onto multiple continuation lines"; let output = render_markdown_line(line, LineKind::Blockquote, &styles, Some(24)); assert!(output.contains('\n'), "wrapped output: {output:?}"); for wrapped in output.split('\n') { assert!( wrapped.contains("│ "), "pipe prefix present on line: {wrapped:?}", ); } } #[test] fn bullet_preserves_leading_indent_when_wrapping() { let styles = test_styles(); let line = " - nested bullet text that wraps around a few times"; let output = render_markdown_line(line, LineKind::BulletItem, &styles, Some(22)); assert!(output.contains('\n'), "wrapped output: {output:?}"); for wrapped in output.split('\n') { assert!( wrapped.starts_with(" "), "leading indent preserved: {wrapped:?}", ); } } #[test] fn block_renderers_produce_single_line_when_wrap_width_none() { let styles = test_styles(); let long_line = "- very long bullet text that would definitely wrap if a narrow wrap_width were set"; let output = render_markdown_line(long_line, LineKind::BulletItem, &styles, None); assert!(!output.contains('\n'), "no wrapping with None: {output:?}"); } #[test] fn bullet_wraps_with_inline_markdown_intact() { let styles = test_styles(); let line = "- **bold** text with `code` that will wrap onto several lines"; let output = render_markdown_line(line, LineKind::BulletItem, &styles, Some(22)); assert!(output.contains('\n'), "wrapped output: {output:?}"); assert!( !output.contains("**bold**"), "asterisks stripped: {output:?}" ); assert!(!output.contains("`code`"), "backticks stripped: {output:?}"); assert!(output.contains("bold")); assert!(output.contains("code")); } #[test] fn bullet_wraps_bold_span_across_boundary() { let styles = test_styles(); let line = "- **explain some code, `value`, using _this_**"; let output = render_markdown_line(line, LineKind::BulletItem, &styles, Some(30)); assert!(output.contains('\n'), "must wrap: {output:?}"); assert!( !output.contains("**"), "bold markers stripped even when span crosses wrap: {output:?}" ); assert!(output.contains("\x1b[1m"), "bold SGR present: {output:?}"); assert!(output.contains("explain")); assert!(output.contains("this")); } #[test] fn blockquote_wraps_bold_span_across_boundary() { let styles = test_styles(); let line = "> **explain some code, `value`, using _this_**"; let output = render_markdown_line(line, LineKind::Blockquote, &styles, Some(30)); assert!(output.contains('\n'), "must wrap: {output:?}"); assert!(!output.contains("**"), "bold markers stripped: {output:?}"); assert!(output.contains("\x1b[1m"), "bold SGR: {output:?}"); } #[test] fn blockquote_restores_base_color_after_inner_reset() { let styles = test_styles(); let line = "> hello **bold** world after `code` more text"; let output = render_markdown_line(line, LineKind::Blockquote, &styles, None); let base_open = SetForegroundColor(styles.blockquote).to_string(); let re_opens = output.matches(base_open.as_str()).count(); assert!( re_opens >= 3, "blockquote color re-opened after each inner reset (got {re_opens}): {output:?}" ); } #[test] fn heading_restores_base_style_after_inner_reset() { let styles = test_styles(); let line = "## title with **bold** and `code` trailing text"; let output = render_markdown_line(line, LineKind::Heading(2), &styles, None); let (heading_color, _) = styles.heading; let base_open = format!("{}\x1b[1m", SetForegroundColor(heading_color)); let re_opens = output.matches(base_open.as_str()).count(); assert!( re_opens >= 3, "heading color+bold re-opened after each inner reset (got {re_opens}): {output:?}" ); } #[test] fn table_header_restores_base_style_after_inner_reset() { let options = RenderOptions::default(); let mut render = MarkdownRender::init(options).unwrap(); let table = "| col with **bold** inside | second |\n|---|---|\n| a | b |\n"; let output = render.render(table); let header_row = output .lines() .find(|l| l.contains("col with")) .expect("header row present"); let bold_opens = header_row.matches("\x1b[1m").count(); assert!( bold_opens >= 3, "table header bold re-opened after inner reset (got {bold_opens}): {header_row:?}" ); } #[test] fn numbered_wraps_bold_span_across_boundary() { let styles = test_styles(); let line = "1. **explain some code, `value`, using _this_**"; let output = render_markdown_line(line, LineKind::NumberedItem, &styles, Some(30)); assert!(output.contains('\n'), "must wrap: {output:?}"); assert!(!output.contains("**"), "bold markers stripped: {output:?}"); assert!(output.contains("\x1b[1m"), "bold SGR: {output:?}"); } #[test] fn task_wraps_bold_span_across_boundary() { let styles = test_styles(); let line = "- [ ] **explain some code, `value`, using _this_**"; let output = render_markdown_line(line, LineKind::TaskItem(false), &styles, Some(32)); assert!(output.contains('\n'), "must wrap: {output:?}"); assert!(!output.contains("**"), "bold markers stripped: {output:?}"); assert!(output.contains("\x1b[1m"), "bold SGR: {output:?}"); } #[test] fn wrapped_bold_line_closes_and_reopens_ansi_across_lines() { let styles = test_styles(); let line = "- **one two three four five six seven eight nine**"; let output = render_markdown_line(line, LineKind::BulletItem, &styles, Some(20)); assert!(output.contains('\n'), "must wrap: {output:?}"); assert!(!output.contains("**"), "bold markers stripped: {output:?}"); for wrapped_line in output.split('\n') { if wrapped_line.contains("\x1b[1m") { assert!( wrapped_line.contains("\x1b[0m"), "each bold line closes with reset: {wrapped_line:?}" ); } } } #[test] fn mixed_content_renders_all_kinds() { let options = RenderOptions::default(); let mut render = MarkdownRender::init(options).unwrap(); let text = "# Heading\n\n\ Some paragraph.\n\n\ - bullet one\n\ - bullet two\n\n\ > a quote\n\n\ | A | B |\n\ |---|---|\n\ | 1 | 2 |\n\n\ Trailing prose.\n"; let body = render.render(text); let tail = render.finalize(); let output = format!("{body}{tail}"); assert!(output.contains("Heading"), "heading rendered"); assert!(output.contains("Some paragraph.")); assert!(output.contains("•"), "bullet glyph rendered"); assert!(output.contains("│"), "blockquote pipe rendered"); assert!( output.contains("A") && output.contains("1"), "table cells rendered" ); assert!( output.chars().any(|c| matches!(c, '\u{2500}'..='\u{257F}')), "table borders rendered", ); assert!(output.contains("Trailing prose.")); } #[test] fn table_renders_without_theme() { let options = RenderOptions { theme: None, ..Default::default() }; let render = MarkdownRender::init(options).unwrap(); let header = vec!["A".into()]; let alignments = vec![CellAlignment::Left]; let output = render.render_table(header, alignments, vec![vec!["1".into()]]); assert!(output.contains("A")); assert!(output.contains("1")); assert!( output.chars().any(|c| matches!(c, '\u{2500}'..='\u{257F}')), "borders present without theme: {output:?}", ); } #[test] fn table_borders_pick_up_theme_color() { let theme = minimal_root_scope_theme(); let styles = MarkdownStyles::from_theme(Some(&theme), true); assert_eq!(styles.table_border, rgb(0x77, 0x77, 0x77)); let options = RenderOptions { theme: Some(theme), ..Default::default() }; let render = MarkdownRender::init(options).unwrap(); let header = vec!["A".into()]; let alignments = vec![CellAlignment::Left]; let output = render.render_table(header, alignments, vec![vec!["1".into()]]); assert!( output.starts_with("\x1b["), "border color SGR at start: {output:?}", ); } #[test] fn table_render_does_not_emit_blank_lines_from_silent_accumulation() { let options = RenderOptions::default(); let mut render = MarkdownRender::init(options).unwrap(); let text = "## Head\n\n\ | A | B |\n\ |---|---|\n\ | 1 | 2 |\n\ | 3 | 4 |\n\ | 5 | 6 |\n\ | 7 | 8 |\n\ | 9 | 10 |\n\n\ trailing\n"; let output = render.render(text); let tail = render.finalize(); let combined = format!("{output}{tail}"); let mut consecutive_blank = 0usize; let mut max_consecutive_blank = 0usize; for line in combined.split('\n') { if line.is_empty() { consecutive_blank += 1; max_consecutive_blank = max_consecutive_blank.max(consecutive_blank); } else { consecutive_blank = 0; } } assert!( max_consecutive_blank <= 1, "at most one blank line between blocks, got {max_consecutive_blank}: {combined:?}", ); } #[test] fn table_tolerates_column_count_mismatch() { let options = RenderOptions::default(); let render = MarkdownRender::init(options).unwrap(); let header = vec!["A".into(), "B".into(), "C".into()]; let alignments = vec![ CellAlignment::Left, CellAlignment::Left, CellAlignment::Left, ]; let rows = vec![vec!["1".into(), "2".into()]]; let output = render.render_table(header, alignments, rows); for cell in ["A", "B", "C", "1", "2"] { assert!(output.contains(cell), "cell {cell:?} present: {output:?}"); } } fn test_styles() -> MarkdownStyles { MarkdownStyles { heading: (Color::Yellow, true), bold: Color::Red, italic: Color::Green, inline_code_fg: Color::Cyan, inline_code_bg: None, blockquote: Color::DarkGrey, list_bullet: Color::Reset, link_text: Color::Blue, link_url: Color::Magenta, strikethrough: Color::DarkGrey, hrule: Color::DarkGrey, table_border: Color::DarkGrey, } } #[test] fn inline_code_strips_backticks() { let styles = test_styles(); let result = apply_inline("hello `world` foo", &styles); assert!(!result.contains('`'), "backticks stripped: {result:?}"); assert!(result.contains("world")); assert!(result.contains("hello ")); assert!(result.contains(" foo")); } #[test] fn bold_asterisk_applied() { let styles = test_styles(); let result = apply_inline("**loud**", &styles); assert!(!result.contains("**"), "markers stripped: {result:?}"); assert!(result.contains("loud")); assert!(result.contains("\x1b[1m"), "bold SGR present: {result:?}"); } #[test] fn bold_underscore_applied() { let styles = test_styles(); let result = apply_inline("__loud__", &styles); assert!(!result.contains("__"), "markers stripped: {result:?}"); assert!(result.contains("loud")); assert!(result.contains("\x1b[1m")); } #[test] fn italic_asterisk_applied() { let styles = test_styles(); let result = apply_inline("*soft*", &styles); assert!(result.contains("soft")); assert!(result.contains("\x1b[3m"), "italic SGR present: {result:?}"); } #[test] fn italic_underscore_applied() { let styles = test_styles(); let result = apply_inline("_soft_", &styles); assert!(result.contains("soft")); assert!(result.contains("\x1b[3m")); } #[test] fn strikethrough_applied() { let styles = test_styles(); let result = apply_inline("~~gone~~", &styles); assert!(!result.contains("~~"), "markers stripped"); assert!(result.contains("gone")); assert!(result.contains("\x1b[9m"), "strikethrough SGR: {result:?}"); } #[test] fn bold_asterisk_wraps_italic_asterisk() { let styles = test_styles(); let result = apply_inline("**loud *soft* loud**", &styles); assert!( !result.contains("**"), "outer bold markers stripped: {result:?}" ); assert!(result.contains("\x1b[1m"), "bold SGR present: {result:?}"); assert!(result.contains("\x1b[3m"), "italic SGR present: {result:?}"); assert!(result.contains("soft")); } #[test] fn bold_underscore_wraps_italic_underscore() { let styles = test_styles(); let result = apply_inline("__loud _soft_ loud__", &styles); assert!( !result.contains("__"), "outer bold markers stripped: {result:?}" ); assert!(result.contains("\x1b[1m"), "bold SGR present: {result:?}"); assert!(result.contains("\x1b[3m"), "italic SGR present: {result:?}"); assert!(result.contains("soft")); } #[test] fn bold_wraps_inline_code() { let styles = test_styles(); let result = apply_inline("**foo `bar` baz**", &styles); assert!(!result.contains('`')); assert!(!result.contains("**")); assert!(result.contains("foo")); assert!(result.contains("bar")); assert!(result.contains("baz")); assert!( result.contains("\x1b[1m"), "bold applied around code: {result:?}" ); } #[test] fn partial_bold_stays_raw() { let styles = test_styles(); let result = apply_inline("**unclosed", &styles); assert_eq!(result, "**unclosed"); } #[test] fn partial_italic_stays_raw() { let styles = test_styles(); assert_eq!(apply_inline("*unclosed", &styles), "*unclosed"); assert_eq!(apply_inline("_unclosed", &styles), "_unclosed"); } #[test] fn italic_ignores_word_internal_underscores() { let styles = test_styles(); assert_eq!(apply_inline("some_var_name", &styles), "some_var_name"); } #[test] fn italic_ignores_math_like_spaces() { let styles = test_styles(); assert_eq!(apply_inline("a * b * c", &styles), "a * b * c"); } #[test] fn links_emit_osc8_and_visible_parts() { let styles = test_styles(); let result = apply_inline("[label](https://example.com)", &styles); assert!( result.contains("\x1b]8;;https://example.com\x1b\\"), "OSC 8 open present: {result:?}" ); assert!(result.contains("\x1b]8;;\x1b\\"), "OSC 8 close present"); assert!(result.contains("label")); assert!(result.contains("https://example.com")); } #[test] fn images_emit_labeled_link() { let styles = test_styles(); let result = apply_inline("![alt text](https://img.example/x.png)", &styles); assert!( !result.contains("!["), "raw image marker removed: {result:?}" ); assert!(result.contains("Image: alt text")); assert!(result.contains("https://img.example/x.png")); assert!(result.contains("\x1b]8;;https://img.example/x.png\x1b\\")); } #[test] fn image_processed_before_link() { let styles = test_styles(); let result = apply_inline("![alt](https://example.com)", &styles); assert!( !result.starts_with('!'), "no stray ! left behind: {result:?}" ); assert!(result.contains("Image:")); } #[test] fn plain_text_unchanged() { let styles = test_styles(); assert_eq!(apply_inline("just plain text", &styles), "just plain text"); } #[test] fn render_heading_level_1_pads_content() { let styles = test_styles(); let result = render_markdown_line("# Big", LineKind::Heading(1), &styles, None); assert!(result.contains(" Big "), "H1 padded content: {result:?}"); assert!(!result.contains('#'), "H1 hashes removed: {result:?}"); assert!(result.contains("\x1b[1m"), "bold applied: {result:?}"); } #[test] fn render_heading_level_2_through_6_keeps_hash_prefix() { let styles = test_styles(); for level in 2u8..=6 { let hashes = "#".repeat(level as usize); let line = format!("{hashes} Title"); let result = render_markdown_line(&line, LineKind::Heading(level), &styles, None); assert!( result.contains(&hashes), "H{level} keeps hashes: {result:?}" ); assert!(result.contains("Title")); assert!(result.contains("\x1b[1m"), "H{level} bold: {result:?}"); } } #[test] fn render_heading_preserves_leading_indent() { let styles = test_styles(); let result = render_markdown_line(" ## Nested", LineKind::Heading(2), &styles, None); assert!(result.starts_with(" "), "indent preserved: {result:?}"); } #[test] fn render_blockquote_uses_pipe_prefix() { let styles = test_styles(); let result = render_markdown_line("> quoted", LineKind::Blockquote, &styles, None); assert!(result.contains("│ "), "pipe prefix: {result:?}"); assert!(!result.contains('>'), "gt removed: {result:?}"); assert!(result.contains("quoted")); } #[test] fn render_blockquote_preserves_indent() { let styles = test_styles(); let result = render_markdown_line(" > deep", LineKind::Blockquote, &styles, None); assert!(result.starts_with(" ")); assert!(result.contains("│ ")); } #[test] fn render_bullet_uses_bullet_char() { let styles = test_styles(); let result = render_markdown_line("- item", LineKind::BulletItem, &styles, None); assert!(result.contains("•"), "bullet glyph: {result:?}"); assert!(!result.contains("- "), "dash removed: {result:?}"); assert!(result.contains("item")); } #[test] fn render_bullet_supports_star_and_plus() { let styles = test_styles(); let star = render_markdown_line("* one", LineKind::BulletItem, &styles, None); let plus = render_markdown_line("+ two", LineKind::BulletItem, &styles, None); assert!(star.contains("•") && star.contains("one")); assert!(plus.contains("•") && plus.contains("two")); } #[test] fn render_bullet_preserves_nested_indent() { let styles = test_styles(); let result = render_markdown_line(" - nested", LineKind::BulletItem, &styles, None); assert!(result.starts_with(" "), "indent kept: {result:?}"); assert!(result.contains("•")); } #[test] fn render_numbered_preserves_number_and_styles_dot() { let styles = test_styles(); let result = render_markdown_line("42. answer", LineKind::NumberedItem, &styles, None); assert!(result.contains("42"), "number kept: {result:?}"); assert!(result.contains("answer")); assert!(result.contains('.'), "dot present"); } #[test] fn render_task_unchecked() { let styles = test_styles(); let result = render_markdown_line("- [ ] todo", LineKind::TaskItem(false), &styles, None); assert!(result.contains("[ ]"), "unchecked glyph: {result:?}"); assert!(!result.contains("- "), "no dash prefix: {result:?}"); assert!(result.contains("todo")); } #[test] fn render_task_checked_uses_check_glyph() { let styles = test_styles(); let result = render_markdown_line("- [x] done", LineKind::TaskItem(true), &styles, None); assert!(result.contains("[✓]"), "checked glyph: {result:?}"); assert!(!result.contains("[x]"), "raw x removed: {result:?}"); assert!(result.contains("done")); } #[test] fn render_hrule_emits_box_drawing() { let styles = test_styles(); let result = render_markdown_line("---", LineKind::HorizontalRule, &styles, None); assert!(result.contains("────"), "box drawing chars: {result:?}"); assert!(!result.contains("---"), "raw dashes removed: {result:?}"); } #[test] fn render_paragraph_delegates_to_inline() { let styles = test_styles(); let result = render_markdown_line("hello **world**", LineKind::Paragraph, &styles, None); assert!(!result.contains("**"), "bold markers stripped: {result:?}"); assert!(result.contains("world")); assert!( result.contains("\x1b[1m"), "bold applied via inline: {result:?}" ); } #[test] fn render_bullet_runs_inline_on_content() { let styles = test_styles(); let result = render_markdown_line("- see `code`", LineKind::BulletItem, &styles, None); assert!(result.contains("•")); assert!(!result.contains('`'), "backticks stripped: {result:?}"); assert!(result.contains("see ")); assert!(result.contains("code")); } #[test] fn render_blockquote_runs_inline_on_content() { let styles = test_styles(); let result = render_markdown_line( "> visit [here](https://example.com)", LineKind::Blockquote, &styles, None, ); assert!(result.contains("│ ")); assert!(result.contains("here")); assert!(result.contains("https://example.com")); assert!(result.contains("\x1b]8;;https://example.com\x1b\\")); } #[test] fn heading_can_contain_bold_inline() { let styles = test_styles(); let result = render_markdown_line("## Announce **now**", LineKind::Heading(2), &styles, None); assert!(!result.contains("**"), "bold markers stripped: {result:?}"); assert!(result.contains("now")); assert!(result.contains("Announce")); assert!(result.contains("\x1b[1m"), "bold present: {result:?}"); } #[test] fn streaming_partial_bold_stays_raw() { let options = RenderOptions::default(); let render = MarkdownRender::init(options).unwrap(); let partial = render.render_line("**bo"); assert!( partial.contains("**bo"), "unclosed bold preserved: {partial:?}" ); } #[test] fn streaming_partial_link_stays_raw() { let options = RenderOptions::default(); let render = MarkdownRender::init(options).unwrap(); let partial = render.render_line("[label](https://exa"); assert!( partial.contains("[label]"), "unclosed link preserved: {partial:?}" ); } #[test] fn rich_render_strips_syntax_without_theme() { let options = RenderOptions::default(); let mut render = MarkdownRender::init(options).unwrap(); let output = render.render("# Heading\n\n> quoted\n\n- item\n"); assert!(!output.contains("# Heading"), "hash removed: {output:?}"); assert!(output.contains("Heading")); assert!(!output.contains("> quoted"), "gt removed"); assert!(output.contains("│ "), "pipe prefix present"); assert!(!output.contains("- item"), "dash removed"); assert!(output.contains("•"), "bullet glyph present"); assert!(output.contains("item")); } #[test] fn rich_and_raw_paths_diverge() { let raw_opts = RenderOptions { raw_markdown: true, ..Default::default() }; let rich_opts = RenderOptions::default(); let mut raw_render = MarkdownRender::init(raw_opts).unwrap(); let mut rich_render = MarkdownRender::init(rich_opts).unwrap(); let text = "# Heading\n\n**bold** text\n"; let raw = raw_render.render(text); let rich = rich_render.render(text); assert_eq!(raw, text, "raw path preserves input"); assert_ne!(rich, text, "rich path transforms input"); } #[test] fn code_block_content_still_routes_to_syntect() { let options = RenderOptions::default(); let mut render = MarkdownRender::init(options).unwrap(); let text = "```rust\nfn main() {}\n```\n"; let output = render.render(text); assert!( output.contains("fn main()"), "code content preserved: {output:?}" ); } #[test] fn markdown_styles_fall_back_to_root_scopes() { let theme = minimal_root_scope_theme(); let styles = MarkdownStyles::from_theme(Some(&theme), true); assert_eq!(styles.heading.0, rgb(0xde, 0xad, 0xbe)); assert!(styles.heading.1); assert_eq!(styles.bold, rgb(0x44, 0x55, 0x66)); assert_eq!(styles.italic, rgb(0x88, 0x88, 0x88)); assert_eq!(styles.inline_code_fg, rgb(0xff, 0xdd, 0x00)); assert_eq!(styles.inline_code_bg, None); assert_eq!(styles.blockquote, rgb(0x88, 0x88, 0x88)); assert_eq!(styles.list_bullet, rgb(0xaa, 0x00, 0xff)); assert_eq!(styles.link_text, rgb(0x11, 0x22, 0x33)); assert_eq!(styles.link_url, rgb(0x00, 0xcc, 0xff)); assert_eq!(styles.strikethrough, rgb(0xff, 0x00, 0x00)); assert_eq!(styles.hrule, rgb(0x88, 0x88, 0x88)); assert_eq!(styles.table_border, rgb(0x77, 0x77, 0x77)); } }