use super::{latex, mermaid, syntax_hl};
use crate::{
core::markdown_table,
modes::interactive::theme::Theme,
utils::display::{char_width, display_width, grapheme_width},
};
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use ratatui::{
style::{Modifier, Style},
text::{Line, Span},
};
use std::{cell::RefCell, rc::Rc};
use unicode_segmentation::UnicodeSegmentation;
type Spans = Vec<Span<'static>>;
const MD_HEADING: (&str, &str) = ("mdHeading", "#f0c674");
const MD_LINK: (&str, &str) = ("mdLink", "#81a2be");
const MD_LINK_URL: (&str, &str) = ("mdLinkUrl", "#666666");
const MD_CODE: (&str, &str) = ("mdCode", "#8abeb7");
const MD_CODE_BLOCK: (&str, &str) = ("mdCodeBlock", "#b5bd68");
const MD_CODE_BLOCK_BORDER: (&str, &str) = ("mdCodeBlockBorder", "#808080");
const MD_QUOTE: (&str, &str) = ("mdQuote", "#808080");
const MD_QUOTE_BORDER: (&str, &str) = ("mdQuoteBorder", "#808080");
const MD_HR: (&str, &str) = ("mdHr", "#808080");
const MD_LIST_BULLET: (&str, &str) = ("mdListBullet", "#8abeb7");
const MD_MATH: (&str, &str) = ("mdCodeBlock", "#b5bd68");
#[derive(Clone, Copy, PartialEq, Eq)]
enum BlockKind {
Heading,
Paragraph,
Code,
List,
Quote,
Table,
Rule,
}
enum OutLine {
Wrap(Spans),
Code(Spans),
Fixed(Spans),
}
enum CodeBlockOutcome {
Done(Vec<OutLine>),
Mermaid(Vec<OutLine>),
}
const PARSE_CACHE_ENTRIES: usize = 64;
const PARSE_CACHE_BYTES: usize = 4 * 1024 * 1024;
thread_local! {
static PARSE_CACHE: RefCell<Vec<ParsedDocument>> = const { RefCell::new(Vec::new()) };
}
struct ParsedDocument {
text: String,
math: bool,
events: Rc<Vec<Event<'static>>>,
}
fn parsed_events(normalized: &str, math: bool, options: Options) -> Rc<Vec<Event<'static>>> {
PARSE_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
if let Some(index) = cache
.iter()
.position(|doc| doc.math == math && doc.text == normalized)
{
let doc = cache.remove(index);
let events = doc.events.clone();
cache.push(doc);
return events;
}
let events: Vec<Event<'static>> = Parser::new_ext(normalized, options)
.map(Event::into_static)
.collect();
cache.push(ParsedDocument {
text: normalized.to_string(),
math,
events: Rc::new(events),
});
while cache.len() > 1
&& (cache.len() > PARSE_CACHE_ENTRIES
|| cache.iter().map(|d| d.text.len()).sum::<usize>() > PARSE_CACHE_BYTES)
{
cache.remove(0);
}
cache
.last()
.map(|doc| doc.events.clone())
.unwrap_or_default()
})
}
trait EventSource<'a> {
fn next_event(&mut self) -> Option<&'a Event<'static>>;
fn peek_event(&self) -> Option<&'a Event<'static>>;
}
struct SliceSource<'a> {
events: &'a [Event<'static>],
pos: usize,
}
impl<'a> SliceSource<'a> {
fn whole(events: &'a [Event<'static>]) -> Self {
Self { events, pos: 0 }
}
}
impl<'a> EventSource<'a> for SliceSource<'a> {
fn next_event(&mut self) -> Option<&'a Event<'static>> {
let event = self.events.get(self.pos)?;
self.pos += 1;
Some(event)
}
fn peek_event(&self) -> Option<&'a Event<'static>> {
self.events.get(self.pos)
}
}
struct RefSource<'a> {
events: Vec<&'a Event<'static>>,
pos: usize,
}
impl<'a> RefSource<'a> {
fn new(events: Vec<&'a Event<'static>>) -> Self {
Self { events, pos: 0 }
}
fn is_empty(&self) -> bool {
self.pos >= self.events.len()
}
}
impl<'a> EventSource<'a> for RefSource<'a> {
fn next_event(&mut self) -> Option<&'a Event<'static>> {
let event = *self.events.get(self.pos)?;
self.pos += 1;
Some(event)
}
fn peek_event(&self) -> Option<&'a Event<'static>> {
self.events.get(self.pos).copied()
}
}
pub struct MdRenderer<'a> {
theme: &'a Theme,
width: usize,
}
pub fn render_markdown(text: &str, width: usize, theme: &Theme) -> Vec<Line<'static>> {
render_markdown_styled(text, width, theme, Style::default())
}
pub fn render_markdown_styled(
text: &str,
width: usize,
theme: &Theme,
base: Style,
) -> Vec<Line<'static>> {
MdRenderer { theme, width }.render_with(text, base)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LinkSpan {
pub line: usize,
pub start_col: usize,
pub end_col: usize,
pub url: String,
}
pub fn collect_link_spans(lines: &[Line<'static>], theme: &Theme) -> Vec<LinkSpan> {
struct Pending {
segments: Vec<(usize, usize, usize)>,
text: String,
url_part: String,
in_url: bool,
}
fn flush(pending: Option<Pending>, out: &mut Vec<LinkSpan>) {
let Some(pending) = pending else {
return;
};
let url = link_target(&pending.url_part, &pending.text);
let mut merged: Vec<(usize, usize, usize)> = Vec::new();
for (line, start, end) in pending.segments {
match merged.last_mut() {
Some(last) if last.0 == line => {
last.1 = last.1.min(start);
last.2 = last.2.max(end);
}
_ => merged.push((line, start, end)),
}
}
for (line, start_col, end_col) in merged {
out.push(LinkSpan {
line,
start_col,
end_col,
url: url.clone(),
});
}
}
let link_fg = theme.style(MD_LINK.0, MD_LINK.1).fg;
let is_link =
|style: Style| style.fg == link_fg && style.add_modifier.contains(Modifier::UNDERLINED);
let mut out: Vec<LinkSpan> = Vec::new();
let mut current: Option<Pending> = None;
let mut last_end: Option<(usize, usize)> = None;
for (row, line) in lines.iter().enumerate() {
let mut col = 0usize;
for span in &line.spans {
let start = col;
col += span.width();
let text = span.content.as_ref();
let contiguous =
last_end.is_some_and(|(lr, lc)| (row == lr && start >= lc) || row == lr + 1);
if is_link(span.style) {
let starts_new =
!contiguous || current.as_ref().is_some_and(|p| !p.url_part.is_empty());
if starts_new {
flush(current.take(), &mut out);
}
match current.as_mut() {
Some(pending) => {
pending.segments.push((row, start, col));
pending.text.push_str(text);
}
None => {
current = Some(Pending {
segments: vec![(row, start, col)],
text: text.to_string(),
url_part: String::new(),
in_url: false,
});
}
}
last_end = Some((row, col));
continue;
}
if text.trim().is_empty() {
continue;
}
let collecting = current.as_ref().is_some_and(|p| p.in_url) && contiguous;
let starts_hint = current.as_ref().is_some_and(|p| {
!p.in_url
&& p.url_part.is_empty()
&& contiguous
&& text.trim_start().starts_with('(')
});
if collecting || starts_hint {
if let Some(pending) = current.as_mut() {
pending.in_url = true;
pending.url_part.push_str(text);
pending.segments.push((row, start, col));
if text.contains(')') {
pending.in_url = false;
}
}
last_end = Some((row, col));
continue;
}
flush(current.take(), &mut out);
last_end = None;
}
}
flush(current.take(), &mut out);
out
}
fn link_target(url_part: &str, text: &str) -> String {
let trimmed = url_part
.trim()
.trim_start_matches('(')
.trim_end_matches(')')
.trim();
let url = if trimmed.is_empty() {
text.trim()
} else {
trimmed
};
if !url.contains(':') && url.contains('@') && !url.contains(char::is_whitespace) {
format!("mailto:{url}")
} else {
url.to_string()
}
}
impl MdRenderer<'_> {
fn render_with(&self, text: &str, base: Style) -> Vec<Line<'static>> {
let width = self.width.max(1);
let normalized = text.replace('\t', " ");
let mut options =
Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS;
let math = self.theme.latex;
if math {
options |= Options::ENABLE_MATH;
}
let events = parsed_events(&normalized, math, options);
let mut source = SliceSource::whole(&events);
let lines = self.render_blocks(&mut source, width, 0, base);
let mut out: Vec<Line<'static>> = Vec::new();
for line in lines {
match line {
OutLine::Wrap(s) => out.extend(wrap_line_spans(s, width)),
OutLine::Code(s) => out.extend(wrap_code_spans(s, width)),
OutLine::Fixed(s) => out.push(Line::from(s)),
}
}
out
}
fn style(&self, key: (&str, &str)) -> Style {
self.theme.style(key.0, key.1)
}
#[stacksafe::stacksafe]
fn render_blocks<'a>(
&self,
src: &mut dyn EventSource<'a>,
width: usize,
depth: usize,
base: Style,
) -> Vec<OutLine> {
let mut out: Vec<OutLine> = Vec::new();
let mut last: Option<BlockKind> = None;
while let Some(ev) = src.next_event() {
let (lines, kind) = match ev {
Event::Start(Tag::Heading { level, .. }) => self.render_heading(src, *level),
Event::Start(Tag::Paragraph) => {
let spans = self.collect_inline(src, base);
(vec![OutLine::Wrap(spans)], BlockKind::Paragraph)
}
Event::Start(Tag::CodeBlock(kind)) => {
match self.render_code_block(src, kind.clone()) {
CodeBlockOutcome::Done(lines) => (lines, BlockKind::Code),
CodeBlockOutcome::Mermaid(lines) => {
out.extend(lines);
last = Some(BlockKind::Code);
continue;
}
}
}
Event::Start(Tag::List(start)) => {
let (ordered, start_num) = match start {
Some(n) => (true, *n),
None => (false, 1),
};
(
self.render_list(src, width, depth, ordered, start_num, base),
BlockKind::List,
)
}
Event::Start(Tag::BlockQuote(_)) => self.render_blockquote(src, width, depth, base),
Event::Start(Tag::Table(_)) => {
let (header, rows) = self.collect_table(src, base);
(self.render_table(&header, &rows, base), BlockKind::Table)
}
Event::Rule => {
let style = self.style(MD_HR);
(
vec![OutLine::Wrap(vec![Span::styled(
"─".repeat(width.min(80)),
style,
)])],
BlockKind::Rule,
)
}
Event::Text(t) => {
let text = t.to_string();
if text.trim().is_empty() {
continue;
}
(
vec![OutLine::Wrap(vec![Span::styled(text, base)])],
BlockKind::Paragraph,
)
}
Event::SoftBreak => (
vec![OutLine::Wrap(vec![Span::styled("\n", Style::default())])],
BlockKind::Paragraph,
),
_ => continue,
};
if let Some(prev) = last
&& prev != BlockKind::List
&& kind != BlockKind::List
&& !out.is_empty()
{
out.push(OutLine::Fixed(vec![Span::raw("")]));
}
last = Some(kind);
out.extend(lines);
}
out
}
fn render_heading<'a>(
&self,
src: &mut dyn EventSource<'a>,
level: HeadingLevel,
) -> (Vec<OutLine>, BlockKind) {
let mut style = self.style(MD_HEADING);
let level: usize = match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
};
if level == 1 {
style = style.add_modifier(Modifier::BOLD | Modifier::UNDERLINED);
} else {
style = style.add_modifier(Modifier::BOLD);
}
let mut line: Spans = Vec::new();
let prefix = format!("{} ", "#".repeat(level));
if level >= 3 {
line.push(Span::styled(prefix, style));
} else {
line.push(Span::raw(prefix));
}
line.extend(self.collect_inline(src, style));
(vec![OutLine::Wrap(line)], BlockKind::Heading)
}
fn render_code_block<'a>(
&self,
src: &mut dyn EventSource<'a>,
kind: CodeBlockKind,
) -> CodeBlockOutcome {
let lang = match kind {
CodeBlockKind::Fenced(l) => l.to_string(),
_ => String::new(),
};
let mut body = String::new();
while let Some(ev) = src.next_event() {
match ev {
Event::Text(t) | Event::Code(t) => body.push_str(t),
Event::End(TagEnd::CodeBlock) => break,
_ => {}
}
}
let (body, partial_fence) = trim_partial_fence(&body, &lang);
let border = self.style(MD_CODE_BLOCK_BORDER);
let code = self.style(MD_CODE_BLOCK);
let mut lines: Vec<OutLine> = vec![OutLine::Wrap(vec![Span::styled(
format!("```{}", lang),
border,
)])];
if self.theme.mermaid
&& lang == "mermaid"
&& let Some(fig) = mermaid::render_mermaid(&body, Some(self.width))
{
let md_color = self.style(MD_CODE_BLOCK);
for l in fig.lines() {
lines.push(OutLine::Fixed(vec![Span::styled(l.to_string(), md_color)]));
}
lines.push(OutLine::Wrap(vec![Span::styled(
format!("```{}", lang),
self.style(MD_CODE_BLOCK_BORDER),
)]));
return CodeBlockOutcome::Mermaid(lines);
}
let syntheme = if self.theme.syntax_highlight && !lang.trim().is_empty() {
Some(syntax_hl::build_theme(self.theme))
} else {
None
};
let mut hl = syntheme
.as_ref()
.and_then(|th| syntax_hl::session(&lang, th));
for l in body.lines() {
let spans: Spans = match hl.as_mut() {
Some(s) => {
let mut v = vec![Span::styled(" ", code)];
v.extend(
s.line(l, code)
.into_iter()
.map(|(st, t)| Span::styled(t, st)),
);
v
}
None => {
vec![Span::styled(" ", code), Span::styled(l.to_string(), code)]
}
};
lines.push(OutLine::Code(spans));
}
if !partial_fence {
lines.push(OutLine::Wrap(vec![Span::styled("```", border)]));
}
CodeBlockOutcome::Done(lines)
}
fn render_blockquote<'a>(
&self,
src: &mut dyn EventSource<'a>,
width: usize,
depth: usize,
base: Style,
) -> (Vec<OutLine>, BlockKind) {
let inner = self.render_blocks(src, width.saturating_sub(2).max(1), depth, base);
let quote = self.style(MD_QUOTE).add_modifier(Modifier::ITALIC);
let border = self.style(MD_QUOTE_BORDER);
let mut lines: Vec<OutLine> = Vec::new();
for line in inner {
let spans = match line {
OutLine::Wrap(s) => wrap_line_spans(s, width.saturating_sub(2).max(1)),
OutLine::Code(s) => wrap_code_spans(s, width.saturating_sub(2).max(1)),
OutLine::Fixed(s) => vec![Line::from(s)],
};
for l in spans {
if l.spans.iter().all(|s| s.content.trim().is_empty()) {
lines.push(OutLine::Fixed(vec![Span::styled("│ ", border)]));
} else {
let mut line: Spans = vec![Span::styled("│ ", border)];
for sp in l.spans {
line.push(Span::styled(sp.content, quote));
}
lines.push(OutLine::Fixed(line));
}
}
}
(lines, BlockKind::Quote)
}
fn collect_inline<'a>(&self, src: &mut dyn EventSource<'a>, style: Style) -> Spans {
let mut out: Spans = Vec::new();
let mut pending = String::new();
let flush = |pending: &mut String, out: &mut Spans| {
if !pending.is_empty() {
out.push(Span::styled(std::mem::take(pending), style));
}
};
while let Some(ev) = src.next_event() {
match ev {
Event::Text(t) => pending.push_str(t),
Event::SoftBreak => pending.push('\n'),
Event::HardBreak => {
flush(&mut pending, &mut out);
out.push(Span::raw("\n"));
}
Event::Code(t) => {
flush(&mut pending, &mut out);
out.push(Span::styled(t.to_string(), self.style(MD_CODE)));
}
Event::Start(Tag::Emphasis) => {
flush(&mut pending, &mut out);
let inner = self.collect_inline(src, style.add_modifier(Modifier::ITALIC));
out.extend(inner);
}
Event::Start(Tag::Strong) => {
flush(&mut pending, &mut out);
let inner = self.collect_inline(src, style.add_modifier(Modifier::BOLD));
out.extend(inner);
}
Event::Start(Tag::Strikethrough) => {
flush(&mut pending, &mut out);
let inner = self.collect_inline(src, style.add_modifier(Modifier::CROSSED_OUT));
out.extend(inner);
}
Event::Start(Tag::Link { dest_url, .. }) => {
flush(&mut pending, &mut out);
let inner = self.collect_inline(src, style);
let text: String = inner.iter().map(|s| s.content.as_ref()).collect();
let href = dest_url.to_string();
out.extend(self.render_link_spans(text, &href));
}
Event::Html(t) => pending.push_str(t),
Event::InlineMath(t) => {
flush(&mut pending, &mut out);
out.push(self.render_inline_math(t));
}
Event::DisplayMath(t) => {
flush(&mut pending, &mut out);
out.push(self.render_display_math(t));
}
Event::End(_) => {
flush(&mut pending, &mut out);
return out;
}
_ => {}
}
}
flush(&mut pending, &mut out);
out
}
fn render_link_spans(&self, text: String, href: &str) -> Spans {
let href_cmp = href.strip_prefix("mailto:").unwrap_or(href);
if text != href && text != href_cmp {
vec![
Span::styled(text, self.style(MD_LINK).add_modifier(Modifier::UNDERLINED)),
Span::styled(format!(" ({})", href), self.style(MD_LINK_URL)),
]
} else {
vec![Span::styled(
text,
self.style(MD_LINK).add_modifier(Modifier::UNDERLINED),
)]
}
}
fn render_inline_math(&self, src: &str) -> Span<'static> {
match latex::render_latex(src, false) {
Some(s) => Span::styled(s, self.style(MD_MATH)),
None => Span::styled(format!("${}$", src), self.style(MD_MATH)),
}
}
fn render_display_math(&self, src: &str) -> Span<'static> {
let src = src.trim().to_string();
match latex::render_latex(&src, true) {
Some(s) => Span::styled(s, self.style(MD_MATH)),
None => Span::styled(format!("$${}$$", src), self.style(MD_MATH)),
}
}
fn render_list<'a>(
&self,
src: &mut dyn EventSource<'a>,
width: usize,
depth: usize,
ordered: bool,
start: u64,
base: Style,
) -> Vec<OutLine> {
let mut out: Vec<OutLine> = Vec::new();
let mut index = start;
let indent = " ".repeat(depth);
while let Some(ev) = src.next_event() {
match ev {
Event::Start(Tag::Item) => {
let mut marker = if ordered {
format!("{}. ", index)
} else {
"- ".to_string()
};
index += 1;
if matches!(src.peek_event(), Some(Event::TaskListMarker(_)))
&& let Some(Event::TaskListMarker(checked)) = src.next_event()
{
marker.push_str(if *checked { "[x] " } else { "[ ] " });
}
let mut item_events: Vec<&'a Event<'static>> = Vec::new();
let mut list_depth = 0usize;
while let Some(ev2) = src.next_event() {
match ev2 {
Event::Start(Tag::List(_)) => list_depth += 1,
Event::End(TagEnd::List(_)) => {
list_depth = list_depth.saturating_sub(1)
}
Event::End(TagEnd::Item) if list_depth == 0 => break,
_ => {}
}
item_events.push(ev2);
}
let mut item_source = RefSource::new(item_events);
let (inner, nested) =
self.render_item_inlines(&mut item_source, width, depth, base);
let marker_w = display_width(&marker);
let item_width = width.saturating_sub(marker_w).max(1);
let bullet = self.style(MD_LIST_BULLET);
out.extend(
self.render_item_lines(
inner, &indent, &marker, marker_w, item_width, bullet,
),
);
for l in nested {
out.push(l);
}
}
Event::End(TagEnd::List(_)) => break,
_ => {}
}
}
out
}
fn render_item_lines(
&self,
inner: Vec<OutLine>,
indent: &str,
marker: &str,
marker_w: usize,
item_width: usize,
bullet: Style,
) -> Vec<OutLine> {
let mut out: Vec<OutLine> = Vec::new();
let mut first = true;
for line in inner {
match line {
OutLine::Code(s) => {
let wrapped = wrap_code_spans(s, item_width);
for l in wrapped {
append_wrapped_item_line(
&mut out, &mut first, indent, marker, marker_w, bullet, l,
);
}
}
OutLine::Wrap(s) => {
let wrapped = wrap_line_spans(s, item_width);
for l in wrapped {
append_wrapped_item_line(
&mut out, &mut first, indent, marker, marker_w, bullet, l,
);
}
}
OutLine::Fixed(s) => {
append_fixed_item_line(
&mut out, &mut first, indent, marker, marker_w, bullet, s,
);
}
}
}
if first {
out.push(OutLine::Fixed(vec![Span::styled(
format!("{}{}", indent, marker),
bullet,
)]));
}
out
}
fn render_item_inlines<'a>(
&self,
src: &mut dyn EventSource<'a>,
width: usize,
depth: usize,
base: Style,
) -> (Vec<OutLine>, Vec<OutLine>) {
let mut out: Vec<OutLine> = Vec::new();
let mut nested: Vec<OutLine> = Vec::new();
loop {
if matches!(src.peek_event(), Some(Event::Start(Tag::Paragraph))) {
src.next_event();
}
let mut seg: Vec<&'a Event<'static>> = Vec::new();
let mut saw_block = false;
while let Some(ev) = src.next_event() {
match ev {
Event::End(TagEnd::Paragraph)
| Event::End(TagEnd::Item)
| Event::End(TagEnd::List(_)) => break,
Event::Start(Tag::List(st)) => {
let (ord2, s2) = match st {
Some(v) => (true, *v),
None => (false, 1),
};
saw_block = true;
nested.extend(self.render_list(src, width, depth + 1, ord2, s2, base));
continue;
}
Event::Start(Tag::CodeBlock(_))
| Event::Start(Tag::BlockQuote(_))
| Event::Start(Tag::Table(_)) => {
saw_block = true;
seg.push(ev);
}
_ => seg.push(ev),
}
}
let mut seg_source = RefSource::new(seg);
if seg_source.is_empty() {
break;
}
let block_lines = if saw_block {
self.render_blocks(&mut seg_source, width, depth, base)
} else {
let spans = self.collect_inline(&mut seg_source, base);
if spans.is_empty() {
continue;
}
vec![OutLine::Wrap(spans)]
};
if !out.is_empty() {
out.push(OutLine::Fixed(vec![Span::raw("")]));
}
out.extend(block_lines);
}
(out, nested)
}
fn collect_table<'a>(
&self,
src: &mut dyn EventSource<'a>,
base: Style,
) -> (Vec<Vec<Span<'static>>>, Vec<Vec<Vec<Span<'static>>>>) {
let mut header: Vec<Vec<Span<'static>>> = Vec::new();
let mut rows: Vec<Vec<Vec<Span<'static>>>> = Vec::new();
let mut cur_row: Option<Vec<Vec<Span<'static>>>> = None;
while let Some(ev) = src.next_event() {
match ev {
Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => {
cur_row = Some(Vec::new())
}
Event::Start(Tag::TableCell) => {
let cell = self.collect_inline(src, base);
if let Some(row) = cur_row.as_mut() {
row.push(cell);
}
}
Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => {
if let Some(row) = cur_row.take() {
if header.is_empty() {
header = row;
} else {
rows.push(row);
}
}
}
Event::End(TagEnd::Table) => break,
_ => {}
}
}
(header, rows)
}
fn render_table(
&self,
header: &[Vec<Span<'static>>],
rows: &[Vec<Vec<Span<'static>>>],
base: Style,
) -> Vec<OutLine> {
let width = self.width;
let num_cols = header.len();
if num_cols == 0 {
return Vec::new();
}
let avail = markdown_table::column_avail(width, num_cols);
let cell_text = |cell: &[Span<'static>]| -> String {
cell.iter().map(|s| s.content.as_ref()).collect()
};
if avail < num_cols {
let text: String = rows
.iter()
.flat_map(|r| r.iter())
.map(|c| cell_text(c))
.collect::<Vec<_>>()
.join(" | ");
return vec![OutLine::Wrap(vec![Span::raw(text)])];
}
let cols = markdown_table::compute_column_widths(header, rows, |c| cell_text(c), avail);
let border = base;
let mut out: Vec<OutLine> = Vec::new();
let horiz = |ch: &str| -> String {
let seg: Vec<String> = cols.iter().map(|w| "─".repeat(*w)).collect();
let (l, r) = match ch {
"─┬─" => ("┌", "┐"),
"─┴─" => ("└", "┘"),
_ => ("├", "┤"),
};
format!("{}{}{}{}{}", l, "─", seg.join(ch), "─", r)
};
out.push(OutLine::Fixed(vec![Span::styled(horiz("─┬─"), border)]));
out.extend(render_table_row(header, &cols, base, true));
out.push(OutLine::Fixed(vec![Span::styled(horiz("─┼─"), border)]));
for (ri, row) in rows.iter().enumerate() {
out.extend(render_table_row(row, &cols, base, false));
if ri < rows.len() - 1 {
out.push(OutLine::Fixed(vec![Span::styled(horiz("─┼─"), border)]));
}
}
out.push(OutLine::Fixed(vec![Span::styled(horiz("─┴─"), border)]));
out
}
}
fn append_wrapped_item_line(
out: &mut Vec<OutLine>,
first: &mut bool,
indent: &str,
marker: &str,
marker_w: usize,
bullet: Style,
l: Line<'static>,
) {
let blank = l.spans.iter().all(|sp| sp.content.trim().is_empty());
if blank && !*first {
out.push(OutLine::Fixed(vec![Span::raw("")]));
return;
}
let mut line: Spans = Vec::new();
if *first {
line.push(Span::styled(format!("{}{}", indent, marker), bullet));
*first = false;
} else {
line.push(Span::styled(
format!("{}{}", indent, " ".repeat(marker_w)),
Style::default(),
));
}
for sp in l.spans {
if sp.content.is_empty() {
continue;
}
line.push(sp);
}
out.push(OutLine::Fixed(line));
}
fn append_fixed_item_line(
out: &mut Vec<OutLine>,
first: &mut bool,
indent: &str,
marker: &str,
marker_w: usize,
bullet: Style,
s: Spans,
) {
let blank = s.iter().all(|sp| sp.content.trim().is_empty());
if blank {
out.push(OutLine::Fixed(vec![Span::raw("")]));
return;
}
let mut line: Spans = Vec::new();
if *first {
line.push(Span::styled(format!("{}{}", indent, marker), bullet));
*first = false;
} else {
line.push(Span::styled(
format!("{}{}", indent, " ".repeat(marker_w)),
Style::default(),
));
}
line.extend(s);
out.push(OutLine::Fixed(line));
}
fn render_table_row(
cells: &[Vec<Span<'static>>],
cols: &[usize],
base: Style,
bold_header: bool,
) -> Vec<OutLine> {
let mut cell_lines: Vec<Vec<Vec<Span<'static>>>> = Vec::new();
for (i, cell) in cells.iter().enumerate() {
let w = cols.get(i).copied().unwrap_or(1);
cell_lines.push(wrap_spans_fixed(cell, w));
}
let n = cell_lines.iter().map(|v| v.len()).max().unwrap_or(1);
let border_st = if bold_header {
base.add_modifier(Modifier::BOLD)
} else {
base
};
let mut out: Vec<OutLine> = Vec::new();
for li in 0..n {
let mut line: Spans = vec![Span::styled("│ ", border_st)];
for (i, cl) in cell_lines.iter().enumerate() {
let w = cols.get(i).copied().unwrap_or(1);
let text_w: usize = cl
.get(li)
.map(|l| l.iter().map(|s| grapheme_width(&s.content)).sum())
.unwrap_or(0);
let pad = w.saturating_sub(text_w);
let mut cell_spans = cl.get(li).cloned().unwrap_or_default();
if bold_header {
for sp in &mut cell_spans {
sp.style = sp.style.add_modifier(Modifier::BOLD);
}
}
line.extend(cell_spans);
if pad > 0 {
line.push(Span::styled(" ".repeat(pad), border_st));
}
line.push(Span::styled(
if i < cell_lines.len() - 1 {
" │ "
} else {
" │"
},
border_st,
));
}
out.push(OutLine::Fixed(line));
}
out
}
fn trim_partial_fence(body: &str, lang: &str) -> (String, bool) {
let trimmed = body.trim_end_matches('\n');
let Some(last_line) = trimmed.lines().last() else {
return (body.to_string(), false);
};
let marker_char = if lang.starts_with('~') { '~' } else { '`' };
let is_partial = !last_line.is_empty()
&& last_line.chars().count() < 3
&& last_line.chars().all(|c| c == marker_char);
if !is_partial {
let is_closed = last_line.trim_end() == "```" || last_line.trim_end() == "~~~";
let _ = is_closed;
return (body.to_string(), false);
}
let mut lines: Vec<&str> = trimmed.lines().collect();
lines.pop();
(lines.join("\n"), true)
}
fn wrap_spans_fixed(spans: &[Span<'static>], width: usize) -> Vec<Vec<Span<'static>>> {
let width = width.max(1);
if spans.is_empty() {
return vec![Vec::new()];
}
let mut out: Vec<Vec<Span<'static>>> = Vec::new();
let mut cur: Vec<Span<'static>> = Vec::new();
let mut cur_w = 0usize;
for sp in spans {
let mut pending = String::new();
for g in UnicodeSegmentation::graphemes(sp.content.as_ref(), true) {
let gw = grapheme_width(g);
if gw == 0 {
continue; }
if cur_w + gw > width && cur_w > 0 {
if !pending.is_empty() {
cur.push(Span::styled(std::mem::take(&mut pending), sp.style));
}
out.push(std::mem::take(&mut cur));
cur_w = 0;
}
pending.push_str(g);
cur_w += gw;
}
if !pending.is_empty() {
cur.push(Span::styled(pending, sp.style));
}
}
if !cur.is_empty() || out.is_empty() {
out.push(cur);
}
out
}
pub fn wrap_line_spans(spans: Spans, width: usize) -> Vec<Line<'static>> {
wrap_spans(spans, width, false)
}
pub fn wrap_code_spans(spans: Spans, width: usize) -> Vec<Line<'static>> {
wrap_spans(spans, width, true)
}
struct Tok {
style: Style,
text: String,
space: bool,
}
fn tokenize_spans(spans: Spans) -> Vec<Tok> {
let mut toks: Vec<Tok> = Vec::new();
for sp in spans {
let mut pending = String::new();
for c in sp.content.chars() {
if c == ' ' || c == '\t' {
if !pending.is_empty() {
toks.push(Tok {
style: sp.style,
text: std::mem::take(&mut pending),
space: false,
});
}
toks.push(Tok {
style: sp.style,
text: if c == '\t' {
" ".to_string()
} else {
" ".to_string()
},
space: true,
});
} else if c == '\n' {
if !pending.is_empty() {
toks.push(Tok {
style: sp.style,
text: std::mem::take(&mut pending),
space: false,
});
}
toks.push(Tok {
style: sp.style,
text: "\n".to_string(),
space: false,
});
} else if c.is_control() {
continue;
} else {
pending.push(c);
if is_cjk(c) {
toks.push(Tok {
style: sp.style,
text: std::mem::take(&mut pending),
space: false,
});
}
}
}
if !pending.is_empty() {
toks.push(Tok {
style: sp.style,
text: pending,
space: false,
});
}
}
toks
}
fn wrap_spans(spans: Spans, width: usize, keep_indent: bool) -> Vec<Line<'static>> {
if width == 0 {
return vec![Line::from(spans)];
}
if spans.iter().all(|s| s.content.is_empty()) {
return vec![Line::from("")];
}
let toks = tokenize_spans(spans);
let mut out: Vec<Line<'static>> = Vec::new();
let mut cur: Vec<(Style, String)> = Vec::new();
let mut cur_w = 0usize;
let push_line = |cur: &mut Vec<(Style, String)>, out: &mut Vec<Line<'static>>| {
let spans: Spans = cur
.iter()
.map(|(s, t)| Span::styled(t.clone(), *s))
.collect();
out.push(Line::from(spans));
cur.clear();
};
for tok in toks {
if tok.text == "\n" {
push_line(&mut cur, &mut out);
cur_w = 0;
continue;
}
let w = display_width(&tok.text);
if cur_w > 0 && cur_w + w > width {
push_line(&mut cur, &mut out);
cur_w = 0;
if tok.space {
continue; }
}
if w > width {
let mut acc = String::new();
for c in tok.text.chars() {
let cw = char_width(c);
if display_width(&acc) + cw > width && !acc.is_empty() {
cur.push((tok.style, std::mem::take(&mut acc)));
push_line(&mut cur, &mut out);
}
acc.push(c);
}
if !acc.is_empty() {
cur.push((tok.style, acc));
cur_w = display_width(cur.last().unwrap().1.as_str());
}
continue;
}
if !keep_indent && tok.space && cur_w == 0 {
continue; }
cur.push((tok.style, tok.text));
cur_w += w;
}
if !cur.is_empty() || out.is_empty() {
push_line(&mut cur, &mut out);
}
out
}
fn is_cjk(c: char) -> bool {
matches!(c as u32,
0x2E80..=0x2EFF | 0x3040..=0x30FF | 0x31F0..=0x31FF |
0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xA000..=0xA4CF |
0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x3FFFD)
}
#[cfg(test)]
mod tests {
use super::*;
fn text_of(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
}
fn theme() -> Theme {
Theme::default()
}
#[test]
fn markdown_links_render_as_visible_text_without_escape_sequences() {
let lines = render_markdown("[example](https://example.com/x)", 60, &theme());
let all = text_of(&lines).join("\n");
assert!(all.contains("example"), "链接文本应可见: {all:?}");
assert!(
all.contains("https://example.com/x"),
"URL 应以文本形式附带: {all:?}"
);
assert!(!all.contains('\x1b'), "不得输出 ESC: {all:?}");
assert!(!all.contains("]8;"), "不得把 OSC8 序列当文本输出: {all:?}");
assert!(!all.contains(char::is_control), "不得输出控制字符: {all:?}");
}
#[test]
fn link_spans_cover_visible_text_and_target() {
let th = theme();
let lines = render_markdown("[example](https://example.com/x)", 60, &th);
let links = collect_link_spans(&lines, &th);
assert_eq!(links.len(), 1, "{links:?}");
let link = &links[0];
assert_eq!(link.line, 0);
assert_eq!(link.start_col, 0);
assert_eq!(link.url, "https://example.com/x");
let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(
&text[link.start_col..link.end_col],
"example (https://example.com/x)",
"命中区应覆盖链接文本与 URL 提示"
);
}
#[test]
fn wrapped_link_yields_one_span_per_line_with_same_url() {
let th = theme();
let url = "https://example.com/very/long/path";
let lines = render_markdown(&format!("[example]({url})"), 20, &th);
let links = collect_link_spans(&lines, &th);
assert!(links.len() >= 2, "长链接应折行成多段: {links:?}");
assert!(links.iter().all(|l| l.url == url), "{links:?}");
assert!(links.iter().all(|l| l.line < lines.len()), "{links:?}");
assert!(links.windows(2).all(|w| w[0].line < w[1].line), "{links:?}");
}
#[test]
fn bare_link_uses_its_text_as_target() {
let th = theme();
let lines = render_markdown("<https://example.com>", 60, &th);
let links = collect_link_spans(&lines, &th);
assert_eq!(links.len(), 1, "{links:?}");
assert_eq!(links[0].url, "https://example.com");
}
#[test]
fn bare_email_link_target_gets_mailto_prefix() {
let th = theme();
let lines = render_markdown("<a@b.com>", 60, &th);
let links = collect_link_spans(&lines, &th);
assert_eq!(links.len(), 1, "{links:?}");
assert_eq!(links[0].url, "mailto:a@b.com");
}
#[test]
fn adjacent_links_stay_separate() {
let th = theme();
let lines = render_markdown("[a](https://a.example)[b](https://b.example)", 60, &th);
let links = collect_link_spans(&lines, &th);
assert_eq!(links.len(), 2, "{links:?}");
assert_eq!(links[0].url, "https://a.example");
assert_eq!(links[1].url, "https://b.example");
}
#[test]
fn no_links_means_no_spans() {
let th = theme();
for md in ["plain text", "`https://example.com`", "```\nhttps://x\n```"] {
let lines = render_markdown(md, 60, &th);
assert!(
collect_link_spans(&lines, &th).is_empty(),
"{md:?} 不应产出链接区域"
);
}
}
#[test]
fn parse_cache_reuses_events_across_theme_and_width() {
let text = "hello **world**\n\n- a\n- b\n";
let narrow = render_markdown(text, 20, &theme());
let cached = parsed_events(text, false, Options::ENABLE_TABLES);
let again = parsed_events(text, false, Options::ENABLE_TABLES);
assert!(Rc::ptr_eq(&cached, &again), "同一文本应命中解析缓存");
let wide = render_markdown(text, 40, &theme());
assert!(!narrow.is_empty() && !wide.is_empty());
let math = parsed_events(text, true, Options::ENABLE_TABLES | Options::ENABLE_MATH);
assert!(!Rc::ptr_eq(&cached, &math), "math 开关不同不应共用条目");
}
#[test]
fn parse_cache_evicts_oldest_entry() {
let first_text = "# evict-0";
let first = parsed_events(first_text, false, Options::ENABLE_TABLES);
for i in 1..=PARSE_CACHE_ENTRIES {
let text = format!("# evict-{i}");
let _ = parsed_events(&text, false, Options::ENABLE_TABLES);
}
let again = parsed_events(first_text, false, Options::ENABLE_TABLES);
assert!(!Rc::ptr_eq(&first, &again), "最旧条目应被淘汰");
}
#[test]
fn inline_math_renders_unicode_formula() {
let lines = render_markdown("勾股定理 — $a^2 + b^2 = c^2$", 60, &theme());
let t = text_of(&lines);
let joined = t.join("\n");
assert!(joined.contains("a² + b² = c²"), "{:?}", t);
assert!(!joined.contains('$'), "分界符应被消费: {:?}", t);
}
#[test]
fn math_list_all_ten_formulas() {
let input = "1. 勾股定理 — $a^2 + b^2 = c^2$\n\
2. 欧拉公式 — $e^{i\\pi} + 1 = 0$\n\
3. 一元二次方程求根 — $x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$\n\
4. 圆的面积 — $A = \\pi r^2$\n\
5. 泰勒级数展开 — $e^x = \\sum_{n=0}^{\\infty} \\frac{x^n}{n!}$\n\
6. 费马小定理 — $a^{p-1} \\equiv 1 \\pmod{p}$\n\
7. 牛顿第二定律 — $F = ma$\n\
8. 概率密度函数(正态分布) — $f(x) = \\frac{1}{\\sigma\\sqrt{2\\pi}} e^{-\\frac{(x-\\mu)^2}{2\\sigma^2}}$\n\
9. 斯特林公式 — $n! \\approx \\sqrt{2\\pi n} \\left(\\frac{n}{e}\\right)^n$\n\
10. 无穷等比级数求和 — $\\sum_{n=0}^{\\infty} ar^n = \\frac{a}{1-r}, \\quad |r| < 1$";
let lines = render_markdown(input, 100, &theme());
let t = text_of(&lines);
let joined = t.join("\n");
for expected in [
"a² + b² = c²",
"e^(iπ) + 1 = 0",
"√(b² - 4ac)",
"A = π r²",
"∑ₙ₌₀^∞",
"aᵖ⁻¹ ≡ 1 (mod p)",
"F = ma",
"1/(σ√2π)",
"√(2π n)",
"arⁿ = a/(1-r)",
] {
assert!(joined.contains(expected), "缺少 {expected:?}: {:?}", t);
}
assert!(!joined.contains("$a^"), "原文不应残留: {:?}", t);
}
#[test]
fn inline_math_spans_styled() {
let lines = render_markdown("$x^2$", 60, &theme());
assert!(
lines
.iter()
.flat_map(|l| l.spans.iter())
.any(|s| s.content.contains('²') && s.style.fg.is_some()),
"公式 span 应有主题色"
);
}
#[test]
fn display_math_stacks_fraction() {
let input = "$$\n\\sum_{n=0}^{\\infty} ar^n = \\frac{a}{1-r}\n$$";
let lines = render_markdown(input, 60, &theme());
let t = text_of(&lines);
let joined = t.join("\n");
assert!(joined.contains('∑'), "求和符号: {:?}", t);
assert!(joined.contains('─'), "display 分式线应堆叠: {:?}", t);
}
#[test]
fn math_in_list_and_table() {
let lines = render_markdown("- $a^n$", 60, &theme());
let t = text_of(&lines);
assert!(t[0].contains("aⁿ"), "列表项内公式: {:?}", t);
let lines = render_markdown("| x |\n|---|\n| $x^2$ |", 40, &theme());
let t = text_of(&lines);
assert!(
t.iter().any(|l| l.contains("x²")),
"表格单元格内公式: {:?}",
t
);
}
#[test]
fn unclosed_math_shows_literal() {
let lines = render_markdown("$x^2", 60, &theme());
let t = text_of(&lines);
assert!(t[0].contains("$x^2"), "未闭合公式应原样显示: {:?}", t);
}
#[test]
fn heading_and_paragraph_spacing() {
let lines = render_markdown("# Title\n\nSome text", 60, &theme());
let t = text_of(&lines);
assert!(t[0].starts_with("# "));
assert!(t[0].contains("Title"));
assert!(t.iter().any(|l| l.trim().is_empty()));
assert!(t.iter().any(|l| l.contains("Some text")));
}
#[test]
fn paragraph_keeps_single_newlines() {
let lines = render_markdown("line1\nline2\nline3", 60, &theme());
let t = text_of(&lines);
assert_eq!(t.len(), 3, "多行段落应渲染为 3 行: {:?}", t);
assert!(
t[0].contains("line1") && t[1].contains("line2") && t[2].contains("line3"),
"{:?}",
t
);
}
#[test]
fn long_paragraph_wraps_across_lines() {
let long = (0..4)
.map(|i| format!("word{}", i))
.collect::<Vec<_>>()
.join(" ");
let lines = render_markdown(&format!("prefix\n{}", long), 20, &theme());
let t = text_of(&lines);
assert!(t[0].contains("prefix"), "首行保留: {:?}", t);
assert!(
t[1..].iter().any(|l| l.contains("word0")),
"后续按宽度折行: {:?}",
t
);
}
#[test]
fn code_block_fences() {
let lines = render_markdown("```rust\nfn main() {}\n```", 60, &theme());
let t = text_of(&lines);
assert!(t.iter().any(|l| l == "```rust"));
assert!(t.iter().any(|l| l == "```"));
assert!(t.iter().any(|l| l.contains("fn main()")));
}
#[test]
fn streaming_partial_fence_trimmed() {
let lines = render_markdown("```rust\nlet x = 1;\n``", 60, &theme());
let t = text_of(&lines);
assert!(
!t.iter().any(|l| l.trim() == "``"),
"partial fence must be trimmed: {:?}",
t
);
assert!(t.iter().any(|l| l.contains("let x = 1;")));
}
#[test]
fn list_markers() {
let lines = render_markdown("- a\n- b", 60, &theme());
let t = text_of(&lines);
assert!(t[0].trim_start().starts_with("- "));
assert!(t[0].contains('a'));
assert!(t[1].contains('b'));
}
#[test]
fn list_item_inline_code_span_is_kept() {
let input = "- `run.sh` — startup script, `todo.md` — task list";
let lines = render_markdown(input, 60, &theme());
let t = text_of(&lines);
assert_eq!(t.len(), 1, "单行渲染,不得拆段: {:?}", t);
let row = &t[0];
assert!(row.contains("run.sh"), "code span 必须保留: {:?}", row);
assert!(row.contains("todo.md"), "code span 必须保留: {:?}", row);
assert!(row.contains("startup script"), "文本内容: {:?}", row);
assert!(!row.contains("- ,"), "不得出现破碎行: {:?}", row);
let lines = render_markdown("- a - b", 40, &theme());
let t = text_of(&lines);
assert!(t[0].contains("- a - b"), "ASCII '-' 原样保留: {:?}", t);
}
#[test]
fn list_item_with_codeblock_keeps_block() {
let input = "- item\n\n ```\n code\n ``";
let lines = render_markdown(input, 60, &theme());
let t = text_of(&lines);
let joined = t.join("\n");
assert!(joined.contains("item"), "{:?}", t);
assert!(joined.contains("code"), "代码块内容保留: {:?}", t);
assert!(!t.iter().any(|l| l.trim() == "``"), "部分围栏修剪: {:?}", t);
}
#[test]
fn nested_list_still_indents() {
let lines = render_markdown("- a\n - b\n - c\n- d", 60, &theme());
let t = text_of(&lines);
assert!(t.iter().any(|l| l.trim_start().starts_with("- a")));
assert!(
t.iter().any(|l| l.starts_with(" - b")),
"嵌套缩进: {:?}",
t
);
assert!(t.iter().any(|l| l.trim_start().starts_with("- d")));
}
#[test]
fn loose_list_item_paragraphs_keep_blank_line() {
let lines = render_markdown("- a\n\n b\n\n- c", 60, &theme());
let t = text_of(&lines);
assert!(t.iter().any(|l| l.contains('a')));
assert!(t.iter().any(|l| l.contains('b')));
assert!(
t.iter().any(|l| l.trim().is_empty()),
"宽松列表段间空行: {:?}",
t
);
}
#[test]
fn ordered_list_numbers() {
let lines = render_markdown("1. one\n2. two", 60, &theme());
let t = text_of(&lines);
assert!(t[0].trim_start().starts_with("1. "));
assert!(t[1].trim_start().starts_with("2. "));
}
#[test]
fn blockquote_gutter() {
let lines = render_markdown("> quoted", 60, &theme());
let t = text_of(&lines);
assert!(t[0].starts_with("│ "));
assert!(t[0].contains("quoted"));
}
#[test]
fn probe() {
use pulldown_cmark::{Event, Options, Parser};
let text = "| a | b |\n|---|--|\n| 1 | 2 |";
let parser = Parser::new_ext(text, Options::all());
for ev in parser {
match ev {
Event::Start(t) => eprintln!("START {:?}", t),
Event::End(t) => eprintln!("END {:?}", t),
Event::Text(t) => eprintln!("TEXT {:?}", t.to_string()),
other => eprintln!("OTHER {:?}", other),
}
}
}
#[test]
fn table_cell_code_span_is_kept() {
let input =
"| 类别 | 依赖 |\n|------|------|\n| HTTP/WebSocket | `reqwest`, `tokio-tungstenite` |";
let lines = render_markdown(input, 60, &theme());
let t = text_of(&lines);
let joined = t.join("\n");
assert!(joined.contains("reqwest"), "code span 必须保留: {:?}", t);
assert!(
joined.contains("tokio-tungstenite"),
"code span 必须保留: {:?}",
t
);
assert!(!joined.contains(", ,"), "不得丢失 code 内容: {:?}", t);
let code_styled = lines.iter().any(|l| {
l.spans
.iter()
.any(|s| s.content.contains("reqwest") && s.style.fg.is_some())
});
assert!(code_styled, "code span 应保留样式: {:?}", t);
}
#[test]
fn table_cell_wraps_keeps_spans() {
let lines = render_markdown("| a | b |\n|---|--|\n| `cccc` dddd | 2 |", 30, &theme());
let t = text_of(&lines);
let joined = t.join("\n");
assert!(joined.contains("cccc"), "code 内容保留: {:?}", t);
assert!(joined.contains("dddd"), "文本内容保留: {:?}", t);
for l in &lines {
let w = l
.spans
.iter()
.map(|s| display_width(&s.content))
.sum::<usize>();
assert!(
w <= 30,
"行宽超限: {:?} (width {})",
text_of(std::slice::from_ref(l)),
w
);
}
}
#[test]
fn table_with_emoji_never_exceeds_width() {
let width = 30usize;
for input in [
"| col | note |\n|-----|------|\n| 1️⃣1️⃣1️⃣1️⃣1️⃣1️⃣1️⃣1️⃣ | x |",
"| col | note |\n|-----|------|\n| 👨👩👧👨👩👧👨👩👧 | x |",
"| col | note |\n|-----|------|\n| ✅ | this explanation is long enough to be wrapped |",
"| col | note |\n|-----|------|\n| averyveryverylongunbreakableword | x |",
] {
let lines = render_markdown(input, width, &theme());
for l in &lines {
let w: usize = l.spans.iter().map(|s| grapheme_width(&s.content)).sum();
assert!(
w <= width,
"行宽超限: {:?} (width {w} > {width})",
text_of(std::slice::from_ref(l))
);
}
}
}
#[test]
fn table_emoji_rows_align_with_border() {
for input in [
"| a | b |\n|---|---|\n| 👨👩👧 | 1 |",
"| a | b |\n|---|---|\n| 1️⃣ | done |",
] {
let lines = render_markdown(input, 40, &theme());
let widths: Vec<usize> = lines
.iter()
.map(|l| l.spans.iter().map(|s| grapheme_width(&s.content)).sum())
.collect();
assert!(
widths.windows(2).all(|w| w[0] == w[1]),
"表格各行宽度不一致 {widths:?}: {:?}",
text_of(&lines)
);
}
}
#[test]
fn table_box() {
let lines = render_markdown("| a | b |\n|---|--|\n| 1 | 2 |", 40, &theme());
for l in &lines {
eprintln!("row: {:?}", text_of(std::slice::from_ref(l)));
}
let t = text_of(&lines);
assert!(t[0].starts_with("┌─"));
assert!(t.iter().any(|l| l.contains('a')), "header missing: {:?}", t);
assert!(t.iter().any(|l| l.contains('1')), "cell missing: {:?}", t);
assert!(
t.last().unwrap().starts_with("└─"),
"bottom missing: {:?}",
t
);
}
#[test]
fn wraps_long_words() {
let lines = wrap_line_spans(vec![Span::raw("abcde fghij")], 8);
let t = text_of(&lines);
assert!(t.len() >= 2);
for l in &t {
assert!(display_width(l) <= 8, "line too wide: {:?}", l);
}
}
#[test]
fn hr_rule() {
let lines = render_markdown("---", 20, &theme());
let t = text_of(&lines);
assert!(t[0].starts_with("─"));
assert_eq!(display_width(&t[0]), 20);
}
}
#[cfg(test)]
mod code_block_tests {
use super::*;
use ratatui::style::Color as RtColor;
fn text_of(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
}
fn theme() -> Theme {
Theme::default()
}
#[test]
fn code_block_keeps_indentation() {
let src = "```zig\nfn pop() {\n self.mutex.lock();\n deep();\n}\n```";
let lines = render_markdown(src, 80, &theme());
let t = text_of(&lines);
assert!(t.iter().any(|l| l == " fn pop() {"), "首行缩进: {:?}", t);
assert!(
t.iter().any(|l| l == " self.mutex.lock();"),
"函数体缩进必须是行首2空格+源码4空格: {:?}",
t
);
assert!(
t.iter().any(|l| l == " deep();"),
"深层缩进保留: {:?}",
t
);
}
#[test]
fn code_block_long_line_wraps_keeps_indent() {
let long = "aaaaaaaaaa bbbbbbbbbb cccccccccc";
let src = format!("```\n {}\n```", long);
let lines = render_markdown(&src, 16, &theme());
let t = text_of(&lines);
assert!(t.len() >= 4, "超宽代码行应折行: {:?}", t);
assert!(
t[1].starts_with(" "),
"折行首行保留 2+4 空格缩进: {:?}",
t
);
assert!(t[1].contains("aaaaaaaaaa"), "内容保留: {:?}", t);
}
#[test]
fn code_block_has_syntax_colors() {
let src = "```zig\nconst x: u32 = 42; // note\n```";
let lines = render_markdown(src, 80, &theme());
let joined = text_of(&lines);
assert!(joined.iter().any(|l| l.contains("const")));
assert!(joined.iter().any(|l| l.contains("42")));
let find = |s: &str| {
lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|sp| sp.content.as_ref() == s)
};
let kw = find("const").expect("const token");
let num = find("42").expect("42 token");
assert!(kw.style.fg.is_some(), "keyword 应着色");
assert!(num.style.fg.is_some(), "number 应着色");
assert_ne!(kw.style.fg, num.style.fg, "keyword 与 number 颜色不同");
assert_eq!(
kw.style.fg,
Some(RtColor::Rgb(0x56, 0x9c, 0xd6)),
"const 应是关键字蓝"
);
let comment = lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|sp| sp.content.as_ref().contains("note"))
.expect("comment token");
assert!(comment.style.fg.is_some(), "comment 应着色");
assert_ne!(comment.style.fg, kw.style.fg, "comment 与 keyword 颜色不同");
}
#[test]
fn code_block_unknown_lang_falls_back_plain() {
let src = "```zzqq\nlet x = 1;\n```";
let lines = render_markdown(src, 80, &theme());
let body = lines
.iter()
.find(|l| l.spans.iter().any(|sp| sp.content.as_ref() == "let"))
.expect("body line");
let t = text_of(std::slice::from_ref(body));
assert!(
t.iter().all(|l| l.contains("x = 1") || l.trim().is_empty()),
"{:?}",
t
);
let md_styled = theme().style(MD_CODE_BLOCK.0, MD_CODE_BLOCK.1);
for sp in &body.spans {
assert_eq!(
sp.style.fg, md_styled.fg,
"回退使用代码块正文色: {:?}",
sp.content
);
}
}
#[test]
fn code_block_fence_token_attrs_still_highlight() {
let md_styled = theme().style(MD_CODE_BLOCK.0, MD_CODE_BLOCK.1);
let src = "```rust,ignore\nfn main() {\n let v: u32 = 1;\n}\n```";
let lines = render_markdown(src, 80, &theme());
let main = lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|sp| sp.content.as_ref() == "main")
.expect("main token");
assert_ne!(
main.style.fg, md_styled.fg,
"rust,ignore 应解析出语法并着色而非回退纯文本"
);
let src2 = "```{rust,linenos}\nfn main() {}\n```";
let lines2 = render_markdown(src2, 80, &theme());
let main2 = lines2
.iter()
.flat_map(|l| l.spans.iter())
.find(|sp| sp.content.as_ref() == "main")
.expect("main token 2");
assert_ne!(main2.style.fg, md_styled.fg, "{{rust,linenos}} 同样应着色");
}
#[test]
fn code_block_cpp_include_blocks_highlight() {
let src = "```cpp\n#include <iostream>\n#include <vector>\nvoid bubbleSort(std::vector<int>& arr) {\n int n = arr.size();\n}\n```";
let lines = render_markdown(src, 80, &theme());
let find = |s: &str| {
lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|sp| sp.content.as_ref() == s)
};
let incs: Vec<_> = lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|sp| sp.content.as_ref() == "#include")
.collect();
assert!(incs.len() >= 2, "应有两条 #include: {:?}", text_of(&lines));
for inc in &incs {
assert_eq!(
inc.style.fg,
Some(RtColor::Rgb(0x56, 0x9c, 0xd6)),
"include 关键字应蓝"
);
}
let void = find("void").expect("void token");
assert_eq!(
void.style.fg,
Some(RtColor::Rgb(0x4e, 0xc9, 0xb0)),
"void 类型应 teal"
);
let fn_name = find("bubbleSort").expect("bubbleSort token");
assert_eq!(
fn_name.style.fg,
Some(RtColor::Rgb(0xdc, 0xdc, 0xaa)),
"函数名应黄"
);
let param = find("arr").expect("arr token");
assert_eq!(
param.style.fg,
Some(RtColor::Rgb(0x9c, 0xdc, 0xfe)),
"参数应浅蓝"
);
}
#[test]
fn code_block_in_blockquote_keeps_indent() {
let src = "> ```\n> a();\n> ```";
let lines = render_markdown(src, 40, &theme());
let t = text_of(&lines);
assert!(
t.iter().any(|l| l.contains("a()") && l.starts_with("│ ")),
"引用内代码保留: {:?}",
t
);
}
#[test]
fn mermaid_sequence_and_gantt_render_structured() {
let src = "```mermaid\n sequenceDiagram\n participant 用户\n participant 系统\n 用户->>系统: 发送请求\n 系统->>用户: 返回响应\n```\n\n```mermaid\n gantt\n title 项目计划\n section 阶段1\n 任务A :a1, 2024-01-01, 30d\n section 阶段2\n 任务B :a2, after a1, 20d\n```";
let lines = render_markdown(src, 80, &theme());
let t = text_of(&lines);
let joined = t.join("\n");
for expect in ["用户", "系统", "发送请求", "返回响应"] {
assert!(joined.contains(expect), "序列内容 {expect} 缺失: {:?}", t);
}
assert!(
t.iter().any(|l| l.starts_with(" ┆ 发送请求")),
"盒图行首缩进应保留(错位回归): {:?}",
t
);
assert!(
!joined.contains("participant 用户"),
"keyword 残留: {:?}",
t
);
for expect in ["项目计划", "阶段1", "阶段2", "任务A", "任务B"] {
assert!(joined.contains(expect), "甘特内容 {expect} 缺失: {:?}", t);
}
assert!(!joined.contains("sequenceDiagram"), "源码不应残留: {:?}", t);
assert!(!joined.contains("gantt\n"), "gantt 关键字不应残留: {:?}", t);
}
#[test]
fn mermaid_too_wide_falls_back_to_source() {
let src = "```mermaid\n graph LR\n A[一个非常非常非常非常非常非常非常长的节点标签] --> B[另一个非常非常非常非常长的标签]\n```";
let lines = render_markdown(src, 24, &theme());
let t = text_of(&lines);
let joined = t.join("\n");
assert!(
joined.contains("graph LR"),
"超宽时不应渲染盒图,应回退源码: {:?}",
t
);
assert!(joined.contains("A["), "回退后源码完整: {:?}", t);
}
#[test]
fn mermaid_disabled_renders_source_block() {
let mut th = theme();
th.mermaid = false;
let src = "```mermaid\ngraph TD\n A --> B\n```";
let lines = render_markdown(src, 80, &th);
let t = text_of(&lines);
let joined = t.join("\n");
assert!(joined.contains("```mermaid"), "围栏保留: {:?}", t);
assert!(joined.contains("graph TD"), "源码原样: {:?}", t);
assert!(joined.contains("A --> B"), "源码原样: {:?}", t);
}
#[test]
fn latex_disabled_renders_literal() {
let mut th = theme();
th.latex = false;
let lines = render_markdown("勾股定理 $a^2 + b^2 = c^2$", 60, &th);
let joined = text_of(&lines).join("\n");
assert!(
joined.contains("$a^2 + b^2 = c^2$"),
"行内公式原文保留: {:?}",
joined
);
assert!(
!joined.contains("a²"),
"不得渲染为 Unicode 公式: {:?}",
joined
);
let lines = render_markdown("$$\n\\sum x\n$$", 60, &th);
let joined = text_of(&lines).join("\n");
assert!(joined.contains("$$"), "display 分界符保留: {:?}", joined);
assert!(joined.contains("\\sum x"), "公式源码保留: {:?}", joined);
}
}