use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span, Text};
use tui_markdown::{Options, StyleSheet};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
const CODE_GUTTER_FG: Color = Color::Cyan;
const HEAD_FG: Color = Color::Cyan;
const TABLE_BORDER_FG: Color = Color::Rgb(90, 98, 120);
#[derive(Clone, Copy, Debug, Default)]
pub struct CodeStyle {
pub bg: Option<Color>,
pub label_bg: Option<Color>,
pub label_right: bool,
pub tab_width: usize,
pub wrap: bool,
}
#[derive(Clone, Copy, Debug, Default)]
struct KonomaStyles {
code_bg: Option<Color>,
}
impl StyleSheet for KonomaStyles {
fn heading(&self, level: u8) -> Style {
let base = Style::new().fg(HEAD_FG).add_modifier(Modifier::BOLD);
match level {
1 | 2 => base, 3 => base.add_modifier(Modifier::ITALIC),
_ => Style::new()
.fg(Color::Cyan)
.add_modifier(Modifier::DIM | Modifier::ITALIC),
}
}
fn code(&self) -> Style {
let s = Style::new().fg(Color::White);
match self.code_bg {
Some(bg) => s.bg(bg),
None => s,
}
}
fn link(&self) -> Style {
Style::new()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED)
}
fn blockquote(&self) -> Style {
Style::new().fg(Color::Green).add_modifier(Modifier::ITALIC)
}
fn heading_meta(&self) -> Style {
Style::new().add_modifier(Modifier::DIM)
}
fn metadata_block(&self) -> Style {
Style::new().fg(Color::LightYellow)
}
}
#[cfg(test)]
pub fn render_markdown(
src: &str,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
) -> Vec<Line<'static>> {
render_markdown_tasks(src, width, code, theme, icons, DEFAULT_TASK_STATES)
}
pub(crate) const DEFAULT_TASK_STATES: &[char] = &[' ', 'x'];
#[cfg(test)]
pub fn render_markdown_tasks(
src: &str,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
tasks: &[char],
) -> Vec<Line<'static>> {
set_details_open(Vec::new());
render_markdown_tasks_opts(src, width, code, theme, icons, tasks, true)
}
fn render_markdown_tasks_opts(
src: &str,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
tasks: &[char],
alerts: bool,
) -> Vec<Line<'static>> {
let mut out = Vec::new();
for seg in split_segments(src) {
match seg {
Segment::Md(text) => {
if text.trim().is_empty() {
continue;
}
if alerts {
for ap in split_alerts(&text) {
match ap {
AlertPart::Text(t) => {
render_md_text(&mut out, &t, width, code, theme, icons, tasks)
}
AlertPart::Alert { kind, title, body } => out.extend(render_alert(
kind, &title, &body, width, code, theme, icons, tasks,
)),
}
}
} else {
render_md_text(&mut out, &text, width, code, theme, icons, tasks);
}
}
Segment::Mermaid(code) => out.extend(render_mermaid_block(&code, width)),
}
}
out
}
fn render_md_text(
out: &mut Vec<Line<'static>>,
text: &str,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
tasks: &[char],
) {
for part in split_details(text) {
match part {
DetailsPart::Text(t) => render_md_text_inner(out, &t, width, code, theme, icons, tasks),
DetailsPart::Details {
open_attr,
summary,
body,
} => {
let open = next_details_open(open_attr);
out.extend(render_details(
open, &summary, &body, width, code, theme, icons, tasks,
));
}
}
}
}
fn render_md_text_inner(
out: &mut Vec<Line<'static>>,
text: &str,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
tasks: &[char],
) {
let opts = Options::new(KonomaStyles { code_bg: code.bg });
for part in split_tables(text) {
match part {
MdPart::Text(t) => {
if t.trim().is_empty() {
continue;
}
for hp in split_html_blocks(&t) {
match hp {
HtmlPart::Text(t2) => {
if t2.trim().is_empty() {
continue;
}
render_text_block_safe(
out, &t2, &opts, width, code, theme, icons, tasks, 0,
);
}
HtmlPart::Html(h) => out.extend(render_html_block(&h)),
}
}
}
MdPart::Table(raw) => out.extend(render_table(&raw, width, icons)),
}
}
}
thread_local! {
static PANIC_SILENCED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
pub(crate) fn catch_silent<T>(f: impl FnOnce() -> T) -> Option<T> {
silence_panics(|| std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok())
}
fn silence_panics<T>(f: impl FnOnce() -> T) -> T {
static INIT: std::sync::Once = std::sync::Once::new();
INIT.call_once(|| {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if !PANIC_SILENCED.with(|c| c.get()) {
prev(info);
}
}));
});
PANIC_SILENCED.with(|c| c.set(true));
let r = f();
PANIC_SILENCED.with(|c| c.set(false));
r
}
fn render_md_segment(src: &str, opts: &Options<KonomaStyles>) -> Option<Vec<Line<'static>>> {
silence_panics(|| {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
into_static_lines(tui_markdown::from_str_with_options(src, opts))
}))
.ok()
})
}
#[derive(Clone, Debug, PartialEq)]
pub struct ImagePlacement {
pub url: String,
pub alt: String,
pub line: usize,
pub cols: u16,
pub rows: u16,
pub fence_ord: Option<usize>,
}
enum BlockPart {
Text(String),
Image {
alt: String,
url: String,
},
Mermaid {
code: String,
},
Math {
latex: String,
display: bool,
},
}
enum MathPart {
Text(String),
Math { latex: String, display: bool },
}
#[derive(Clone, Debug, PartialEq)]
pub enum MathSlot {
Image { cols: u16, rows: u16 },
Loading,
Raw,
}
#[derive(Clone, Debug, PartialEq)]
pub enum MermaidSlot {
Image { cols: u16, rows: u16 },
Loading,
Text,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ImageSlot {
Inline { cols: u16, rows: u16 },
Loading,
Unavailable,
}
#[allow(clippy::too_many_arguments)] pub fn render_markdown_with_images(
src: &str,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
tasks: &[char],
slot_of: &dyn Fn(&str) -> ImageSlot,
mermaid_slot: &dyn Fn(&str) -> MermaidSlot,
mermaid_caption: &str,
alerts: bool,
math_slot: &dyn Fn(&str, bool) -> MathSlot,
math_on: bool,
) -> (Vec<Line<'static>>, Vec<ImagePlacement>) {
let mut out: Vec<Line<'static>> = Vec::new();
let mut placements: Vec<ImagePlacement> = Vec::new();
let fences_on = !matches!(mermaid_slot(""), MermaidSlot::Text);
let mut fence_ord = 0usize;
let parts: Vec<BlockPart> = if math_on {
split_block_parts(src, fences_on)
.into_iter()
.flat_map(|p| match p {
BlockPart::Text(t) => split_math(&t)
.into_iter()
.map(|mp| match mp {
MathPart::Text(s) => BlockPart::Text(s),
MathPart::Math { latex, display } => BlockPart::Math { latex, display },
})
.collect::<Vec<_>>(),
other => vec![other],
})
.collect()
} else {
split_block_parts(src, fences_on)
};
for part in parts {
match part {
BlockPart::Text(t) => out.extend(render_markdown_tasks_opts(
&t, width, code, theme, icons, tasks, alerts,
)),
BlockPart::Image { alt, url } => match slot_of(&url) {
ImageSlot::Inline { cols, rows } => {
placements.push(ImagePlacement {
url,
alt: alt.clone(),
line: out.len(),
cols,
rows,
fence_ord: None,
});
out.extend(image_placeholder_lines(cols, rows, &alt, width));
}
ImageSlot::Loading => out.extend(image_loading_line(&alt, &url, width)),
ImageSlot::Unavailable => out.extend(image_text_fallback(&alt, &url, width)),
},
BlockPart::Mermaid { code: fence } => {
let ord = fence_ord;
fence_ord += 1;
if fence.trim().is_empty() {
out.extend(render_mermaid_block(&fence, width));
continue;
}
match mermaid_slot(&fence) {
MermaidSlot::Image { cols, rows } => {
let url = mermaid_fence_url(&fence);
let mut ls = mermaid_placeholder_lines(cols, rows, width, mermaid_caption);
out.push(ls.remove(0));
placements.push(ImagePlacement {
url,
alt: "mermaid".into(),
line: out.len(),
cols,
rows,
fence_ord: Some(ord),
});
out.extend(ls);
}
MermaidSlot::Loading => {
out.extend(image_loading_line("mermaid", "diagram", width))
}
MermaidSlot::Text => out.extend(render_mermaid_block(&fence, width)),
}
}
BlockPart::Math { latex, display } => match math_slot(&latex, display) {
MathSlot::Image { cols, rows } => {
placements.push(ImagePlacement {
url: math_url(&latex, display),
alt: "math".into(),
line: out.len(),
cols,
rows,
fence_ord: None,
});
out.extend(math_placeholder_lines(cols, rows, width, display));
}
MathSlot::Loading => out.extend(image_loading_line("math", "equation", width)),
MathSlot::Raw => out.extend(math_raw_lines(&latex, display)),
},
}
}
(out, placements)
}
fn math_placeholder_lines(cols: u16, rows: u16, width: u16, display: bool) -> Vec<Line<'static>> {
let rows = rows.max(1);
let pad = if display {
(width.saturating_sub(cols) / 2) as usize
} else {
0
};
let indent = " ".repeat(pad);
let mut lines = Vec::with_capacity(rows as usize);
for _ in 0..rows {
lines.push(Line::from(indent.clone()));
}
lines
}
fn math_raw_lines(latex: &str, display: bool) -> Vec<Line<'static>> {
let text = if display {
format!("$$ {} $$", latex.trim())
} else {
format!("${}$", latex.trim())
};
vec![Line::from(Span::from(text).dim())]
}
fn mermaid_header_style() -> Style {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::ITALIC)
}
pub fn is_mermaid_header_span(span: &Span<'_>) -> bool {
span.style == mermaid_header_style() && span.content.starts_with("◇ mermaid")
}
fn mermaid_placeholder_lines(
cols: u16,
rows: u16,
width: u16,
caption: &str,
) -> Vec<Line<'static>> {
let rows = rows.max(1);
let pad = (width.saturating_sub(cols) / 2) as usize;
let indent = " ".repeat(pad);
let mut lines = Vec::with_capacity(rows as usize + 2);
lines.push(Line::from(vec![
Span::raw(indent),
Span::styled(format!("◇ mermaid — {caption}"), mermaid_header_style()),
]));
for _ in 0..rows {
lines.push(Line::from(String::new()));
}
lines.push(Line::from(String::new()));
lines
}
pub fn is_remote_image_url(url: &str) -> bool {
let lower = url.trim().to_ascii_lowercase();
lower.starts_with("http://") || lower.starts_with("https://")
}
pub fn collect_remote_image_urls(src: &str) -> Vec<String> {
let mut urls = Vec::new();
for part in split_block_images(src) {
if let BlockPart::Image { url, .. } = part {
if is_remote_image_url(&url) {
urls.push(url);
}
}
}
urls
}
fn split_block_images(src: &str) -> Vec<BlockPart> {
split_block_parts(src, false)
}
fn split_block_parts(src: &str, mermaid_fences: bool) -> Vec<BlockPart> {
let mut parts = Vec::new();
let mut text = String::new();
let mut open: Option<(u8, usize)> = None;
let mut mermaid: Option<String> = None;
for line in src.split_inclusive('\n') {
let bare = line.strip_suffix('\n').unwrap_or(line);
match open {
None => {
if let Some((fence, info)) = parse_fence(bare) {
open = Some((fence.ch, fence.len));
if mermaid_fences && is_mermaid_info(&info) {
if !text.is_empty() {
parts.push(BlockPart::Text(std::mem::take(&mut text)));
}
mermaid = Some(String::new());
} else {
text.push_str(line);
}
} else if let Some((alt, url)) = extract_block_image(bare) {
if !text.is_empty() {
parts.push(BlockPart::Text(std::mem::take(&mut text)));
}
parts.push(BlockPart::Image { alt, url });
} else {
text.push_str(line);
}
}
Some((ch, len)) => {
let closing = parse_fence(bare)
.map(|(f, info)| f.ch == ch && f.len >= len && info.is_empty())
.unwrap_or(false);
match (&mut mermaid, closing) {
(Some(code), true) => {
parts.push(BlockPart::Mermaid {
code: std::mem::take(code),
});
mermaid = None;
}
(Some(code), false) => code.push_str(line),
(None, _) => text.push_str(line),
}
if closing {
open = None;
}
}
}
}
if let Some(code) = mermaid {
text.push_str("```mermaid\n");
text.push_str(&code);
}
if !text.is_empty() {
parts.push(BlockPart::Text(text));
}
parts
}
fn extract_block_image(line: &str) -> Option<(String, String)> {
let t = line.trim();
if t.is_empty() {
return None;
}
if let Some(img) = extract_html_img(t) {
return Some(img);
}
extract_md_img(t)
}
fn extract_html_img(t: &str) -> Option<(String, String)> {
let lower = t.to_ascii_lowercase();
let pos = lower.find("<img")?;
let after = lower[pos + 4..].chars().next()?;
if !after.is_whitespace() && after != '>' && after != '/' {
return None;
}
if !html_is_only_tags(t) {
return None;
}
let tag_end = lower[pos..].find('>').map(|i| pos + i)?;
let tag = &t[pos..tag_end];
let url = html_attr(tag, "src")?;
let alt = html_attr(tag, "alt").unwrap_or_default();
Some((alt, url))
}
fn html_is_only_tags(t: &str) -> bool {
let mut depth = 0i32;
for c in t.chars() {
match c {
'<' => depth += 1,
'>' => depth = (depth - 1).max(0),
_ if depth > 0 => {}
c if c.is_whitespace() => {}
_ => return false,
}
}
true
}
fn html_attr(tag: &str, name: &str) -> Option<String> {
let lower = tag.to_ascii_lowercase();
let mut search = 0usize;
while let Some(rel) = lower[search..].find(name) {
let i = search + rel;
let before_ok = i == 0 || lower.as_bytes()[i - 1].is_ascii_whitespace();
if before_ok {
let after = tag[i + name.len()..].trim_start();
if let Some(rest) = after.strip_prefix('=') {
let rest = rest.trim_start();
if let Some(q) = rest.chars().next() {
if (q == '"' || q == '\'') && rest.len() > 1 {
if let Some(end) = rest[1..].find(q) {
return Some(rest[1..1 + end].to_string());
}
}
}
}
}
search = i + name.len();
}
None
}
fn extract_md_img(t: &str) -> Option<(String, String)> {
let bang = t.find("![")?;
let prefix = t[..bang].trim();
if !(prefix.is_empty() || prefix == "[") {
return None;
}
let rest = &t[bang + 2..];
let close_alt = rest.find(']')?;
let alt = rest[..close_alt].to_string();
let after_alt = rest[close_alt + 1..].trim_start();
let after_alt = after_alt.strip_prefix('(')?;
let close_url = after_alt.find(')')?;
let url = after_alt[..close_url]
.split_whitespace()
.next()
.unwrap_or("")
.to_string();
if url.is_empty() {
return None;
}
let suffix = after_alt[close_url + 1..].trim();
let ok_suffix = suffix.is_empty() || (prefix == "[" && suffix.starts_with(']'));
if !ok_suffix {
return None;
}
Some((alt, url))
}
fn image_placeholder_lines(cols: u16, rows: u16, alt: &str, width: u16) -> Vec<Line<'static>> {
let rows = rows.max(1);
let pad = (width.saturating_sub(cols) / 2) as usize;
let indent = " ".repeat(pad);
let alt = alt.trim();
let label = if alt.is_empty() {
"🖼 image".to_string()
} else {
format!("🖼 {alt}")
};
let label = truncate_width(&label, cols as usize);
let mut lines = Vec::with_capacity(rows as usize);
lines.push(Line::from(format!("{indent}{label}")).dim());
for _ in 1..rows {
lines.push(Line::from(String::new()));
}
lines
}
fn image_text_fallback(alt: &str, url: &str, width: u16) -> Vec<Line<'static>> {
let alt = alt.trim();
let s = if alt.is_empty() {
format!("🖼 {url}")
} else {
format!("🖼 {alt} — {url}")
};
vec![Line::from(truncate_width(&s, width as usize)).dim()]
}
fn image_loading_line(alt: &str, url: &str, width: u16) -> Vec<Line<'static>> {
let alt = alt.trim();
let what = if alt.is_empty() { url } else { alt };
let s = format!("🖼 {what} — loading…");
vec![Line::from(truncate_width(&s, width as usize)).dim()]
}
fn truncate_width(s: &str, max: usize) -> String {
if s.width() <= max {
return s.to_string();
}
let budget = max.saturating_sub(1);
let mut out = String::new();
let mut w = 0usize;
for c in s.chars() {
let cw = c.width().unwrap_or(0);
if w + cw > budget {
break;
}
out.push(c);
w += cw;
}
out.push('…');
out
}
fn decorate_md_lines(
lines: Vec<Line<'static>>,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
tasks: &[char],
) -> Vec<Line<'static>> {
let lines = decorate_code_blocks(lines, width, code, theme);
let lines = decorate_headings(lines, width);
decorate_extras(lines, width, icons, tasks)
}
fn decorate_extras(
lines: Vec<Line<'static>>,
width: u16,
icons: bool,
tasks: &[char],
) -> Vec<Line<'static>> {
lines
.into_iter()
.map(|l| {
let joined: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
let t = joined.trim();
if t == "---" || t == "***" || t == "___" {
return Line::from(Span::styled(
"─".repeat(width as usize),
Style::new().fg(TABLE_BORDER_FG),
));
}
replace_task_checkbox(l, &joined, icons, tasks)
})
.collect()
}
fn replace_task_checkbox(
l: Line<'static>,
joined: &str,
icons: bool,
tasks: &[char],
) -> Line<'static> {
let Some(state) = task_prefix_state(joined.trim_start(), tasks) else {
return l;
};
let pat = format!("[{state}]");
let Some(pos) = joined.find(&pat) else {
return l;
};
let trail_space = joined[pos + pat.len()..].starts_with(' ');
let end = pos + pat.len() + usize::from(trail_space);
let (style, alignment) = (l.style, l.alignment);
let mut out: Vec<Span<'static>> = Vec::new();
let mut off = 0usize;
let mut inserted = false;
for sp in l.spans {
let s = sp.content.as_ref();
let (a, b) = (off, off + s.len());
off = b;
if b <= pos || a >= end {
out.push(sp); continue;
}
if a < pos {
out.push(Span::styled(s[..pos - a].to_string(), sp.style));
}
if !inserted {
let mut disp = task_marker_display(state, icons);
if trail_space {
disp.push(' ');
}
out.push(Span::styled(disp, task_marker_style()));
inserted = true;
}
if b > end {
out.push(Span::styled(s[end - a..].to_string(), sp.style));
}
}
let mut nl = Line::from(out).style(style);
nl.alignment = alignment;
nl
}
fn task_prefix_state(t: &str, tasks: &[char]) -> Option<char> {
let rest = t.strip_prefix("- [")?;
let c = rest.chars().next()?;
if !rest[c.len_utf8()..].starts_with("] ") {
return None;
}
is_task_state(c, tasks).then_some(c)
}
fn is_task_state(c: char, tasks: &[char]) -> bool {
c == ' ' || c == 'x' || c == 'X' || tasks.contains(&c)
}
fn task_marker_display(state: char, icons: bool) -> String {
match state {
' ' if icons => crate::ui::icons::task_icon(false).to_string(),
'x' | 'X' if icons => crate::ui::icons::task_icon(true).to_string(),
' ' => "[ ]".into(),
'x' | 'X' => "[x]".into(),
c => format!("[{c}]"),
}
}
pub(crate) fn task_marker_style() -> Style {
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD)
}
pub(crate) fn is_task_span(span: &Span<'_>) -> bool {
span.style == task_marker_style() && task_span_state(span.content.as_ref()).is_some()
}
pub(crate) fn task_span_state(s: &str) -> Option<char> {
let s = s.strip_suffix(' ').unwrap_or(s);
if s == crate::ui::icons::task_icon(false).to_string() {
return Some(' ');
}
if s == crate::ui::icons::task_icon(true).to_string() {
return Some('x');
}
let inner = s.strip_prefix('[')?.strip_suffix(']')?;
let mut it = inner.chars();
let c = it.next()?;
it.next().is_none().then_some(c)
}
pub(crate) fn code_header_marker_style() -> Style {
Style::new()
.fg(CODE_GUTTER_FG)
.add_modifier(Modifier::ITALIC)
}
pub(crate) fn is_code_header_span(span: &Span<'_>) -> bool {
span.style.fg == Some(CODE_GUTTER_FG)
&& span.style.add_modifier.contains(Modifier::ITALIC)
&& !span.style.add_modifier.contains(Modifier::BOLD)
&& span.content.as_ref().starts_with('▎')
}
pub(crate) fn is_inline_code_span(span: &Span<'_>) -> bool {
span.style.fg == Some(Color::White)
}
pub(crate) fn is_code_line(line: &Line<'_>) -> bool {
line.spans
.iter()
.any(|s| s.content.starts_with('▎') && s.style.fg == Some(CODE_GUTTER_FG))
}
pub(crate) fn code_block_source_locs(src: &str) -> Vec<String> {
let lines: Vec<&str> = src.lines().collect();
let mut out = Vec::new();
let mut i = 0;
while i < lines.len() {
let t = lines[i].trim_start();
let fence = if t.starts_with("```") {
Some('`')
} else if t.starts_with("~~~") {
Some('~')
} else {
None
};
let Some(fc) = fence else {
i += 1;
continue;
};
let info = t.trim_start_matches(fc).trim();
let is_mermaid = is_mermaid_info(info);
let close = fc.to_string().repeat(3);
i += 1; let mut body = Vec::new();
while i < lines.len() && !lines[i].trim_start().starts_with(&close) {
body.push(lines[i].to_string());
i += 1;
}
if i < lines.len() {
i += 1; }
if !is_mermaid {
out.push(body.join("\n"));
}
}
out
}
pub(crate) struct TaskLoc {
pub line: usize,
pub state_off: usize,
pub state: char,
}
pub(crate) fn task_source_locs(src: &str, tasks: &[char]) -> Vec<TaskLoc> {
let lines: Vec<&str> = src.lines().collect();
let mut out = Vec::new();
let mut fence: Option<char> = None;
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let t = line.trim_start();
if let Some(f) = fence {
if t.starts_with(&f.to_string().repeat(3)) {
fence = None;
}
i += 1;
continue;
}
if t.starts_with("```") {
fence = Some('`');
i += 1;
continue;
}
if t.starts_with("~~~") {
fence = Some('~');
i += 1;
continue;
}
if is_html_block_start(line) {
while i < lines.len() && !lines[i].trim().is_empty() {
i += 1;
}
continue;
}
if looks_like_table_row(line) && i + 1 < lines.len() && is_table_delimiter(lines[i + 1]) {
i += 2;
while i < lines.len() && looks_like_table_row(lines[i]) {
i += 1;
}
continue;
}
let indent = line.len() - t.len();
if let Some(state) = task_prefix_state(t, tasks) {
out.push(TaskLoc {
line: i,
state_off: indent + 3, state,
});
}
i += 1;
}
out
}
fn decorate_code_blocks(
lines: Vec<Line<'static>>,
width: u16,
code: CodeStyle,
theme: &str,
) -> Vec<Line<'static>> {
let w = width as usize;
let code_bg = code.bg;
let mut out = Vec::with_capacity(lines.len());
let mut in_code = false;
let mut lang = String::new();
let mut body: Vec<String> = Vec::new();
for line in lines {
let text = line.to_string();
let trimmed = text.trim_start();
if !in_code && trimmed.starts_with("```") {
in_code = true;
lang = trimmed.trim_matches('`').trim().to_string();
let label = if lang.is_empty() {
"code"
} else {
lang.as_str()
};
out.push(code_header(label, w, code));
body.clear();
continue;
}
if in_code && is_closing_fence(trimmed) {
in_code = false;
out.extend(highlight_body(
&body,
&lang,
w,
code_bg,
theme,
code.tab_width,
code.wrap,
));
out.push(pad_to_width(vec![gutter_span(code_bg)], w, code_bg));
body.clear();
continue;
}
if in_code {
body.push(text);
continue;
}
out.push(line);
}
if in_code {
out.extend(highlight_body(
&body,
&lang,
w,
code_bg,
theme,
code.tab_width,
code.wrap,
));
out.push(pad_to_width(vec![gutter_span(code_bg)], w, code_bg));
}
out
}
#[derive(Clone, PartialEq, Eq, Hash)]
struct CodeBlockKey {
src: String,
lang: String,
w: usize,
code_bg: Option<Color>,
theme: String,
tab_width: usize,
wrap: bool,
}
struct CodeBlockCache {
map: std::collections::HashMap<CodeBlockKey, (u64, Vec<Line<'static>>)>,
tick: u64,
}
const CODE_BLOCK_CACHE_CAP: usize = 64;
fn code_block_cache() -> &'static std::sync::Mutex<CodeBlockCache> {
static CACHE: std::sync::OnceLock<std::sync::Mutex<CodeBlockCache>> =
std::sync::OnceLock::new();
CACHE.get_or_init(|| {
std::sync::Mutex::new(CodeBlockCache {
map: std::collections::HashMap::new(),
tick: 0,
})
})
}
#[cfg(test)]
pub(crate) fn code_block_cache_len() -> usize {
code_block_cache().lock().map(|c| c.map.len()).unwrap_or(0)
}
#[allow(clippy::too_many_arguments)]
fn highlight_body(
body: &[String],
lang: &str,
w: usize,
code_bg: Option<Color>,
theme: &str,
tab_width: usize,
wrap: bool,
) -> Vec<Line<'static>> {
if body.is_empty() {
return Vec::new();
}
let src = body.join("\n");
let key = CodeBlockKey {
src: src.clone(),
lang: lang.to_string(),
w,
code_bg,
theme: theme.to_string(),
tab_width,
wrap,
};
if let Ok(mut cache) = code_block_cache().lock() {
cache.tick += 1;
let tick = cache.tick;
if let Some((used, lines)) = cache.map.get_mut(&key) {
*used = tick;
return lines.clone();
}
}
let hl = crate::preview::code::expand_tabs(
crate::preview::code::highlight_lang(&src, lang, theme),
tab_width,
);
let content_w = w.saturating_sub(GUTTER_COLS).max(1);
let out: Vec<Line<'static>> = hl
.into_iter()
.flat_map(|line| {
let styled: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|s| {
let st = match code_bg {
Some(bg) => s.style.bg(bg),
None => s.style,
};
Span::styled(s.content, st)
})
.collect();
let rows = if wrap {
wrap_spans_by_width(styled, content_w)
} else {
vec![styled]
};
rows.into_iter().map(move |chunk| {
let mut spans = vec![gutter_span(code_bg)];
spans.extend(chunk);
pad_to_width(spans, w, code_bg)
})
})
.collect();
if let Ok(mut cache) = code_block_cache().lock() {
let tick = cache.tick;
cache.map.insert(key, (tick, out.clone()));
while cache.map.len() > CODE_BLOCK_CACHE_CAP {
let oldest = cache
.map
.iter()
.min_by_key(|(_, (used, _))| *used)
.map(|(k, _)| k.clone());
match oldest {
Some(k) => {
cache.map.remove(&k);
}
None => break,
}
}
}
out
}
#[allow(clippy::too_many_arguments)]
fn render_text_block_safe(
out: &mut Vec<Line<'static>>,
src: &str,
opts: &Options<KonomaStyles>,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
tasks: &[char],
depth: u8,
) {
if src.trim().is_empty() {
return;
}
if let Some(lines) = render_md_segment(src, opts) {
out.extend(decorate_md_lines(lines, width, code, theme, icons, tasks));
return;
}
if depth >= 8 {
out.extend(src.lines().map(|l| Line::from(l.to_string())));
return;
}
match split_block_for_retry(src) {
Some((a, b)) => {
render_text_block_safe(out, a, opts, width, code, theme, icons, tasks, depth + 1);
render_text_block_safe(out, b, opts, width, code, theme, icons, tasks, depth + 1);
}
None => out.extend(src.lines().map(|l| Line::from(l.to_string()))),
}
}
fn split_block_for_retry(src: &str) -> Option<(&str, &str)> {
let mut blanks: Vec<usize> = Vec::new(); let mut newlines: Vec<usize> = Vec::new(); let mut in_fence = false;
let mut off = 0usize;
for line in src.split_inclusive('\n') {
let t = line.trim_start();
if t.starts_with("```") || t.starts_with("~~~") {
in_fence = !in_fence;
}
let end = off + line.len();
if !in_fence && line.ends_with('\n') {
if line.trim().is_empty() {
blanks.push(off);
}
newlines.push(end);
}
off = end;
}
let mid = src.len() / 2;
let pick = |cands: &[usize]| -> Option<usize> {
cands
.iter()
.copied()
.filter(|&i| i > 0 && i < src.len())
.min_by_key(|&i| i.abs_diff(mid))
};
if let Some(i) = pick(&blanks) {
return Some((&src[..i], &src[i..]));
}
let cut = pick(&newlines)?;
if cut == 0 || cut >= src.len() {
return None;
}
Some((&src[..cut], &src[cut..]))
}
const GUTTER_COLS: usize = 2;
fn wrap_spans_by_width(spans: Vec<Span<'static>>, maxw: usize) -> Vec<Vec<Span<'static>>> {
use unicode_width::UnicodeWidthChar;
let mut rows: Vec<Vec<Span<'static>>> = Vec::new();
let mut cur: Vec<Span<'static>> = Vec::new();
let mut used = 0usize;
for sp in spans {
let mut buf = String::new();
for ch in sp.content.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + cw > maxw && used > 0 {
if !buf.is_empty() {
cur.push(Span::styled(std::mem::take(&mut buf), sp.style));
}
rows.push(std::mem::take(&mut cur));
used = 0;
}
buf.push(ch);
used += cw;
}
if !buf.is_empty() {
cur.push(Span::styled(buf, sp.style));
}
}
rows.push(cur);
rows
}
fn decorate_headings(lines: Vec<Line<'static>>, width: u16) -> Vec<Line<'static>> {
let w = width as usize;
let mut out = Vec::with_capacity(lines.len());
for line in lines {
if let Some(level) = heading_level(&line) {
let style = line.style;
let mut spans = line.spans;
spans.remove(0); out.push(Line::from(spans).style(style));
if level <= 2 {
let ch = if level == 1 { "━" } else { "─" };
out.push(Line::from(Span::styled(
ch.repeat(w),
Style::new().fg(HEAD_FG).add_modifier(Modifier::DIM),
)));
}
} else {
out.push(line);
}
}
out
}
pub(crate) fn heading_text(line: &Line<'_>) -> Option<String> {
if line.style.fg != Some(HEAD_FG) {
return None;
}
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
if text.starts_with('▎') {
return None; }
let t = text.trim();
let t = t.strip_prefix('▌').map(str::trim_start).unwrap_or(t);
if t.is_empty() || t.chars().all(|c| c == '━' || c == '─') {
return None;
}
Some(t.to_string())
}
pub(crate) fn heading_level_hint(line: &Line<'_>, next: Option<&Line<'_>>) -> u8 {
let m = line.style.add_modifier;
if m.contains(Modifier::DIM) {
return 4;
}
if m.contains(Modifier::ITALIC) {
return 3;
}
let rule: String = next
.map(|n| n.spans.iter().map(|s| s.content.as_ref()).collect())
.unwrap_or_default();
if rule.starts_with('━') {
1
} else {
2
}
}
fn heading_level(line: &Line) -> Option<u8> {
let content = line.spans.first()?.content.as_ref();
let hashes = content.strip_suffix(' ')?;
if !hashes.is_empty() && hashes.len() <= 6 && hashes.bytes().all(|b| b == b'#') {
Some(hashes.len() as u8)
} else {
None
}
}
fn gutter_span(code_bg: Option<Color>) -> Span<'static> {
let st = Style::new().fg(CODE_GUTTER_FG);
let st = match code_bg {
Some(bg) => st.bg(bg),
None => st,
};
Span::styled("▎ ", st)
}
fn code_header(label: &str, w: usize, code: CodeStyle) -> Line<'static> {
let code_bg = code.bg;
let gutter = {
let st = code_header_marker_style();
let st = match code_bg {
Some(bg) => st.bg(bg),
None => st,
};
Span::styled("▎ ", st)
};
let badge_text = format!(" {label} "); let badge_style = match code.label_bg {
Some(bg) => Style::new()
.fg(Color::White)
.bg(bg)
.add_modifier(Modifier::BOLD),
None => Style::new()
.fg(Color::Gray)
.add_modifier(Modifier::ITALIC | Modifier::DIM),
};
let gutter_w = gutter.width();
let badge_w = UnicodeWidthStr::width(badge_text.as_str());
let badge = Span::styled(badge_text, badge_style);
let fill_style = code_bg.map(|bg| Style::new().bg(bg)).unwrap_or_default();
let mut spans = vec![gutter];
if code.label_right && w > gutter_w + badge_w {
spans.push(Span::styled(" ".repeat(w - gutter_w - badge_w), fill_style));
spans.push(badge);
} else {
let used = gutter_w + badge_w;
spans.push(badge);
if w > used {
spans.push(Span::styled(" ".repeat(w - used), fill_style));
}
}
let line = Line::from(spans);
match code_bg {
Some(bg) => line.style(Style::new().bg(bg)),
None => line,
}
}
fn pad_to_width(mut spans: Vec<Span<'static>>, w: usize, code_bg: Option<Color>) -> Line<'static> {
let Some(bg) = code_bg else {
return Line::from(spans);
};
let used: usize = spans.iter().map(|s| s.width()).sum();
if used < w {
spans.push(Span::styled(" ".repeat(w - used), Style::new().bg(bg)));
}
Line::from(spans).style(Style::new().bg(bg))
}
fn is_closing_fence(trimmed: &str) -> bool {
let t = trimmed.trim_end();
t.len() >= 3 && t.bytes().all(|b| b == b'`')
}
pub fn render_mermaid_file(src: &str, width: u16) -> Vec<Line<'static>> {
render_mermaid_block(src, width)
}
fn render_mermaid_block(code: &str, width: u16) -> Vec<Line<'static>> {
let max_width = if width == 0 {
None
} else {
Some(width as usize)
};
match render_mermaid_safe(code.trim_end_matches('\n'), max_width) {
Ok(rendered) => rendered
.lines()
.map(|l| Line::from(l.to_string()))
.collect(),
Err(note) => fallback_raw(code, ¬e),
}
}
fn render_mermaid_safe(code: &str, max_width: Option<usize>) -> Result<String, String> {
let caught = silence_panics(|| {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
mermaid_text::render_with_width(code, max_width)
}))
});
match caught {
Ok(Ok(s)) => Ok(s),
Ok(Err(e)) => Err(format!("cannot render mermaid: {e}")),
Err(_) => Err(
"cannot render mermaid (internal error: this diagram/char may be unsupported)"
.to_string(),
),
}
}
pub fn mermaid_to_svg(code: &str, theme: &str) -> Option<String> {
use mermaid_rs_renderer::{RenderOptions, Theme};
let modern = Theme::modern();
let mut t = match theme {
"light" | "modern" => Theme::modern(),
"classic" | "mermaid" => Theme::mermaid_default(),
"forest" => Theme::forest(),
"neutral" => Theme::neutral(),
_ => Theme::dark(), };
t.background = "none".to_string();
t.font_family = modern.font_family.clone();
t.font_size = modern.font_size;
let opts = RenderOptions {
theme: t,
..RenderOptions::default()
};
let caught = silence_panics(|| {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
mermaid_rs_renderer::render_with_options(code.trim_end_matches('\n'), opts)
}))
});
match caught {
Ok(Ok(svg)) => Some(svg),
_ => None,
}
}
pub fn warm_mermaid() {
let _ = mermaid_to_svg("graph LR\nA-->B", "dark");
}
pub fn mermaid_fence_url(code: &str) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &b in code.as_bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("mermaid-fence://{h:016x}")
}
pub fn is_mermaid_fence_url(url: &str) -> bool {
url.starts_with("mermaid-fence://")
}
pub fn collect_mermaid_fences(src: &str) -> Vec<String> {
split_block_parts(src, true)
.into_iter()
.filter_map(|p| match p {
BlockPart::Mermaid { code } => Some(code),
_ => None,
})
.collect()
}
pub fn math_url(latex: &str, display: bool) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &b in latex.as_bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("math://{}{h:016x}", if display { "d" } else { "i" })
}
pub fn is_math_url(url: &str) -> bool {
url.starts_with("math://")
}
pub fn is_synthetic_md_url(url: &str) -> bool {
is_mermaid_fence_url(url) || is_math_url(url)
}
pub fn collect_math_exprs(src: &str) -> Vec<(String, bool)> {
split_block_parts(src, false)
.into_iter()
.flat_map(|p| match p {
BlockPart::Text(t) => split_math(&t)
.into_iter()
.filter_map(|mp| match mp {
MathPart::Math { latex, display } => Some((latex, display)),
MathPart::Text(_) => None,
})
.collect::<Vec<_>>(),
_ => Vec::new(),
})
.collect()
}
fn utf8_len(b: u8) -> usize {
if b < 0x80 {
1
} else if b >> 5 == 0b110 {
2
} else if b >> 4 == 0b1110 {
3
} else {
4
}
}
fn flush_math(out: &mut Vec<MathPart>, buf: &mut String, latex: &str, display: bool) {
if !buf.is_empty() {
out.push(MathPart::Text(std::mem::take(buf)));
}
out.push(MathPart::Math {
latex: latex.trim().to_string(),
display,
});
}
fn split_math(text: &str) -> Vec<MathPart> {
let mut out = Vec::new();
let mut buf = String::new();
let lines: Vec<&str> = text.split_inclusive('\n').collect();
let mut in_fence: Option<(u8, usize)> = None;
let mut i = 0;
while i < lines.len() {
let raw = lines[i];
let bare = raw.strip_suffix('\n').unwrap_or(raw);
if let Some((ch, len)) = in_fence {
buf.push_str(raw);
if let Some((f, info)) = parse_fence(bare) {
if f.ch == ch && f.len >= len && info.is_empty() {
in_fence = None;
}
}
i += 1;
continue;
}
if let Some((f, _info)) = parse_fence(bare) {
in_fence = Some((f.ch, f.len));
buf.push_str(raw);
i += 1;
continue;
}
let trimmed = bare.trim();
if trimmed == "$$" || trimmed == "\\[" {
let closer = if trimmed == "$$" { "$$" } else { "\\]" };
let mut body = String::new();
let mut j = i + 1;
let mut found = false;
while j < lines.len() {
let bj = lines[j].strip_suffix('\n').unwrap_or(lines[j]);
if bj.trim() == closer {
found = true;
break;
}
body.push_str(lines[j]);
j += 1;
}
if found && !body.trim().is_empty() {
flush_math(&mut out, &mut buf, &body, true);
i = j + 1; continue;
}
}
scan_inline_math(bare, &mut out, &mut buf);
buf.push('\n');
i += 1;
}
if !buf.is_empty() {
out.push(MathPart::Text(buf));
}
out
}
fn scan_inline_math(line: &str, out: &mut Vec<MathPart>, buf: &mut String) {
let bytes = line.as_bytes();
let n = line.len();
let mut i = 0;
while i < n {
let c = bytes[i];
if c == b'`' {
let start = i;
let mut run = 0;
while i < n && bytes[i] == b'`' {
run += 1;
i += 1;
}
let mut j = i;
let mut close = None;
while j < n {
if bytes[j] == b'`' {
let mut r = 0;
while j < n && bytes[j] == b'`' {
r += 1;
j += 1;
}
if r == run {
close = Some(j);
break;
}
} else {
j += 1;
}
}
match close {
Some(end) => {
buf.push_str(&line[start..end]);
i = end;
}
None => buf.push_str(&line[start..i]), }
continue;
}
if c == b'\\' && i + 1 < n {
match bytes[i + 1] {
b'(' => {
if let Some((content, end)) = find_close(line, i + 2, "\\)") {
flush_math(out, buf, content, false);
i = end;
continue;
}
}
b'[' => {
if let Some((content, end)) = find_close(line, i + 2, "\\]") {
flush_math(out, buf, content, true);
i = end;
continue;
}
}
_ => {}
}
let end = (i + 1 + utf8_len(bytes[i + 1])).min(n);
buf.push_str(&line[i..end]);
i = end;
continue;
}
if c == b'$' {
if i + 1 < n && bytes[i + 1] == b'$' {
if let Some((content, end)) = find_close(line, i + 2, "$$") {
if !content.trim().is_empty() {
flush_math(out, buf, content, true);
i = end;
continue;
}
}
buf.push('$');
i += 1;
continue;
}
if let Some((content, end)) = find_inline_dollar(line, i + 1) {
flush_math(out, buf, content, false);
i = end;
continue;
}
buf.push('$');
i += 1;
continue;
}
let len = utf8_len(c);
buf.push_str(&line[i..(i + len).min(n)]);
i += len;
}
}
fn find_close<'a>(line: &'a str, from: usize, needle: &str) -> Option<(&'a str, usize)> {
let rel = line.get(from..)?.find(needle)?;
let pos = from + rel;
Some((&line[from..pos], pos + needle.len()))
}
fn find_inline_dollar(line: &str, from: usize) -> Option<(&str, usize)> {
let bytes = line.as_bytes();
let n = line.len();
if from >= n || bytes[from].is_ascii_whitespace() || bytes[from] == b'$' {
return None; }
let mut j = from;
while j < n {
match bytes[j] {
b'\\' => j += 1 + bytes.get(j + 1).map_or(0, |&b| utf8_len(b)),
b'$' => {
let content = &line[from..j];
let last_ok = !bytes[j - 1].is_ascii_whitespace();
let after_digit = bytes.get(j + 1).is_some_and(|b| b.is_ascii_digit());
if last_ok && !after_digit && !content.is_empty() {
return Some((content, j + 1));
}
return None; }
_ => j += utf8_len(bytes[j]),
}
}
None
}
fn fallback_raw(code: &str, note: &str) -> Vec<Line<'static>> {
let mut v = vec![Line::from(Span::from(format!("[{note}]")).dim())];
for l in code.lines() {
v.push(Line::from(Span::from(format!(" {l}")).dim()));
}
v
}
fn into_static_lines(text: Text) -> Vec<Line<'static>> {
text.lines.into_iter().map(line_into_static).collect()
}
fn line_into_static(line: Line) -> Line<'static> {
let spans: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|s| Span::styled(s.content.into_owned(), s.style))
.collect();
let mut out = Line::from(spans).style(line.style);
if let Some(alignment) = line.alignment {
out = out.alignment(alignment);
}
out
}
#[derive(Debug, PartialEq)]
enum Segment {
Md(String),
Mermaid(String),
}
struct Fence {
ch: u8,
len: usize,
}
fn parse_fence(line: &str) -> Option<(Fence, String)> {
let trimmed = line.trim_start();
let ch = *trimmed.as_bytes().first()?;
if ch != b'`' && ch != b'~' {
return None;
}
let len = trimmed.bytes().take_while(|&b| b == ch).count();
if len < 3 {
return None;
}
let info = trimmed[len..].trim().to_string();
Some((Fence { ch, len }, info))
}
fn is_mermaid_info(info: &str) -> bool {
info.split_whitespace()
.next()
.is_some_and(|w| w.eq_ignore_ascii_case("mermaid"))
}
fn split_segments(src: &str) -> Vec<Segment> {
let mut segments = Vec::new();
let mut md = String::new();
let mut mermaid = String::new();
let mut open: Option<(Fence, bool)> = None;
for line in src.split_inclusive('\n') {
let bare = line.strip_suffix('\n').unwrap_or(line);
match &open {
None => {
if let Some((fence, info)) = parse_fence(bare) {
if is_mermaid_info(&info) {
if !md.is_empty() {
segments.push(Segment::Md(std::mem::take(&mut md)));
}
open = Some((fence, true));
} else {
md.push_str(line);
open = Some((fence, false));
}
} else {
md.push_str(line);
}
}
Some((fence, is_mermaid)) => {
let closing = parse_fence(bare)
.map(|(f, info)| f.ch == fence.ch && f.len >= fence.len && info.is_empty())
.unwrap_or(false);
if closing {
if *is_mermaid {
segments.push(Segment::Mermaid(std::mem::take(&mut mermaid)));
} else {
md.push_str(line); }
open = None;
} else if *is_mermaid {
mermaid.push_str(line);
} else {
md.push_str(line);
}
}
}
}
if !md.is_empty() {
segments.push(Segment::Md(md));
}
if let Some((_, true)) = open {
if !mermaid.is_empty() {
segments.push(Segment::Mermaid(mermaid));
}
}
segments
}
enum MdPart {
Text(String),
Table(String),
}
enum HtmlPart {
Text(String),
Html(String),
}
fn is_html_block_start(line: &str) -> bool {
let t = line.trim_start();
if t.starts_with("<!--") {
return true;
}
let Some(rest) = t.strip_prefix('<') else {
return false;
};
let rest = rest.strip_prefix('/').unwrap_or(rest);
let name_len = rest
.char_indices()
.take_while(|(i, c)| {
if *i == 0 {
c.is_ascii_alphabetic()
} else {
c.is_ascii_alphanumeric() || *c == '-'
}
})
.count();
if name_len == 0 {
return false;
}
matches!(
rest[name_len..].chars().next(),
None | Some(' ') | Some('\t') | Some('>') | Some('/')
)
}
fn split_html_blocks(md: &str) -> Vec<HtmlPart> {
let lines: Vec<&str> = md.lines().collect();
let mut parts = Vec::new();
let mut buf: Vec<&str> = Vec::new();
let mut i = 0;
while i < lines.len() {
if is_html_block_start(lines[i]) {
if !buf.is_empty() {
parts.push(HtmlPart::Text(buf.join("\n") + "\n"));
buf.clear();
}
let mut block: Vec<&str> = Vec::new();
while i < lines.len() && !lines[i].trim().is_empty() {
block.push(lines[i]);
i += 1;
}
parts.push(HtmlPart::Html(block.join("\n")));
continue;
}
buf.push(lines[i]);
i += 1;
}
if !buf.is_empty() {
parts.push(HtmlPart::Text(buf.join("\n") + "\n"));
}
parts
}
fn render_html_block(raw: &str) -> Vec<Line<'static>> {
let mut text = String::new();
let mut rest = raw;
while let Some(pos) = rest.find('<') {
text.push_str(&rest[..pos]);
let after = &rest[pos..];
if let Some(r) = after.strip_prefix("<!--") {
match r.find("-->") {
Some(e) => rest = &r[e + 3..],
None => rest = "",
}
} else {
match after.find('>') {
Some(e) => rest = &after[e + 1..],
None => rest = "",
}
}
}
text.push_str(rest);
let text = text
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace(" ", " ");
let mut out: Vec<Line<'static>> = text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(|l| Line::from(Span::raw(l.to_string())))
.collect();
if !out.is_empty() {
out.push(Line::from(""));
}
out
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum AlertKind {
Note,
Tip,
Important,
Warning,
Caution,
}
impl AlertKind {
fn parse(s: &str) -> Option<AlertKind> {
match s.trim().to_ascii_lowercase().as_str() {
"note" | "info" => Some(AlertKind::Note),
"tip" | "hint" => Some(AlertKind::Tip),
"important" => Some(AlertKind::Important),
"warning" | "attention" => Some(AlertKind::Warning),
"caution" | "danger" | "error" => Some(AlertKind::Caution),
_ => None,
}
}
fn label(self) -> &'static str {
match self {
AlertKind::Note => "Note",
AlertKind::Tip => "Tip",
AlertKind::Important => "Important",
AlertKind::Warning => "Warning",
AlertKind::Caution => "Caution",
}
}
fn color(self) -> Color {
match self {
AlertKind::Note => Color::Blue,
AlertKind::Tip => Color::Green,
AlertKind::Important => Color::Magenta,
AlertKind::Warning => Color::Yellow,
AlertKind::Caution => Color::Red,
}
}
fn icon(self) -> char {
match self {
AlertKind::Note => '\u{f05a}', AlertKind::Tip => '\u{f0eb}', AlertKind::Important => '\u{f0a1}', AlertKind::Warning => '\u{f071}', AlertKind::Caution => '\u{f06a}', }
}
}
enum AlertPart {
Text(String),
Alert {
kind: AlertKind,
title: String,
body: String,
},
}
fn parse_alert_header(line: &str) -> Option<(AlertKind, String)> {
let rest = line.trim_start().strip_prefix('>')?.trim_start();
let rest = rest.strip_prefix("[!")?;
let close = rest.find(']')?;
let kind = AlertKind::parse(&rest[..close])?;
Some((kind, rest[close + 1..].trim().to_string()))
}
fn is_blockquote_line(line: &str) -> bool {
line.trim_start().starts_with('>')
}
fn strip_blockquote(line: &str) -> String {
let l = line.trim_start();
let l = l.strip_prefix('>').unwrap_or(l);
l.strip_prefix(' ').unwrap_or(l).to_string()
}
fn split_alerts(md: &str) -> Vec<AlertPart> {
let mut parts = Vec::new();
let mut text = String::new();
let lines: Vec<&str> = md.lines().collect();
let mut i = 0;
while i < lines.len() {
if let Some((kind, title)) = parse_alert_header(lines[i]) {
if !text.is_empty() {
parts.push(AlertPart::Text(std::mem::take(&mut text)));
}
i += 1;
let mut body = String::new();
while i < lines.len() && is_blockquote_line(lines[i]) {
body.push_str(&strip_blockquote(lines[i]));
body.push('\n');
i += 1;
}
parts.push(AlertPart::Alert { kind, title, body });
} else {
text.push_str(lines[i]);
text.push('\n');
i += 1;
}
}
if !text.is_empty() {
parts.push(AlertPart::Text(text));
}
parts
}
#[allow(clippy::too_many_arguments)]
fn render_alert(
kind: AlertKind,
title: &str,
body: &str,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
tasks: &[char],
) -> Vec<Line<'static>> {
let color = kind.color();
let bar = || Span::styled("▌ ".to_string(), Style::new().fg(color));
let mut out = Vec::new();
let mut header = vec![bar()];
if icons {
header.push(Span::styled(
format!("{} ", kind.icon()),
Style::new().fg(color),
));
}
let label = if title.is_empty() {
kind.label().to_string()
} else {
format!("{} — {}", kind.label(), title)
};
header.push(Span::styled(
label,
Style::new().fg(color).add_modifier(Modifier::BOLD),
));
out.push(Line::from(header));
let inner = width.saturating_sub(2);
let mut body_lines = Vec::new();
render_md_text_inner(&mut body_lines, body, inner, code, theme, icons, tasks);
for bl in body_lines {
let style = bl.style;
let mut spans = vec![bar()];
spans.extend(bl.spans);
out.push(Line::from(spans).style(style));
}
out
}
thread_local! {
static DETAILS: std::cell::RefCell<(usize, Vec<bool>)> =
const { std::cell::RefCell::new((0, Vec::new())) };
}
pub fn set_details_open(open: Vec<bool>) {
DETAILS.with(|d| *d.borrow_mut() = (0, open));
}
pub fn current_details_states() -> Vec<bool> {
DETAILS.with(|d| d.borrow().1.clone())
}
fn next_details_open(open_attr: bool) -> bool {
DETAILS.with(|d| {
let mut d = d.borrow_mut();
let ord = d.0;
let v = d.1.get(ord).copied().unwrap_or(open_attr);
d.0 = ord + 1;
v
})
}
enum DetailsPart {
Text(String),
Details {
open_attr: bool,
summary: String,
body: String,
},
}
fn details_open_tag(line: &str) -> Option<bool> {
let t = line.trim();
let lower = t.to_ascii_lowercase();
let rest = lower.strip_prefix("<details")?;
if !(rest.is_empty() || rest.starts_with('>') || rest.starts_with(' ')) {
return None; }
Some(rest.contains("open"))
}
fn is_details_close(line: &str) -> bool {
line.trim().eq_ignore_ascii_case("</details>")
}
fn split_details(md: &str) -> Vec<DetailsPart> {
let lines: Vec<&str> = md.lines().collect();
let mut parts = Vec::new();
let mut text = String::new();
let mut in_fence = false;
let mut i = 0;
while i < lines.len() {
let t = lines[i].trim_start();
if t.starts_with("```") || t.starts_with("~~~") {
in_fence = !in_fence;
text.push_str(lines[i]);
text.push('\n');
i += 1;
continue;
}
if !in_fence {
if let Some(open_attr) = details_open_tag(lines[i]) {
if !text.is_empty() {
parts.push(DetailsPart::Text(std::mem::take(&mut text)));
}
i += 1;
let mut block = Vec::new();
while i < lines.len() && !is_details_close(lines[i]) {
block.push(lines[i]);
i += 1;
}
if i < lines.len() {
i += 1; }
let (summary, body) = extract_summary_body(&block.join("\n"));
parts.push(DetailsPart::Details {
open_attr,
summary,
body,
});
continue;
}
}
text.push_str(lines[i]);
text.push('\n');
i += 1;
}
if !text.is_empty() {
parts.push(DetailsPart::Text(text));
}
parts
}
fn extract_summary_body(inner: &str) -> (String, String) {
let lower = inner.to_ascii_lowercase();
if let (Some(s), Some(e)) = (lower.find("<summary"), lower.find("</summary>")) {
if s < e {
if let Some(gt) = inner[s..e].find('>') {
let sum = strip_inline_html_tags(inner[s + gt + 1..e].trim());
let body = inner[e + "</summary>".len()..].trim().to_string();
return (sum, body);
}
}
}
(String::new(), inner.trim().to_string())
}
fn strip_inline_html_tags(s: &str) -> String {
let mut out = String::new();
let mut rest = s;
while let Some(lt) = rest.find('<') {
out.push_str(&rest[..lt]);
match rest[lt..].find('>') {
Some(gt) => rest = &rest[lt + gt + 1..],
None => {
rest = "";
break;
}
}
}
out.push_str(rest);
out.trim().to_string()
}
pub(crate) fn details_marker_style() -> Style {
Style::new()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD | Modifier::ITALIC)
}
pub(crate) fn is_details_header_span(span: &Span<'_>) -> bool {
span.style == details_marker_style()
&& (span.content.starts_with('▸') || span.content.starts_with('▾'))
}
#[allow(clippy::too_many_arguments)]
fn render_details(
open: bool,
summary: &str,
body: &str,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
tasks: &[char],
) -> Vec<Line<'static>> {
let mut out = Vec::new();
let arrow = if open { '▾' } else { '▸' };
let label = if summary.trim().is_empty() {
"Details"
} else {
summary.trim()
};
out.push(Line::from(vec![
Span::styled(format!("{arrow} "), details_marker_style()),
Span::styled(label.to_string(), Style::new().add_modifier(Modifier::BOLD)),
]));
if open && !body.trim().is_empty() {
let bar = || Span::styled("▏ ".to_string(), Style::new().fg(TABLE_BORDER_FG));
let inner = width.saturating_sub(2);
let mut body_lines = Vec::new();
render_md_text_inner(&mut body_lines, body, inner, code, theme, icons, tasks);
for bl in body_lines {
let style = bl.style;
let mut spans = vec![bar()];
spans.extend(bl.spans);
out.push(Line::from(spans).style(style));
}
}
out
}
pub fn collect_details_open(src: &str) -> Vec<bool> {
split_details(src)
.into_iter()
.filter_map(|p| match p {
DetailsPart::Details { open_attr, .. } => Some(open_attr),
DetailsPart::Text(_) => None,
})
.collect()
}
fn sup_char(c: char) -> Option<char> {
Some(match c {
'0'..='9' => ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'][c as usize - '0' as usize],
'+' => '⁺',
'-' => '⁻',
'=' => '⁼',
'(' => '⁽',
')' => '⁾',
'n' => 'ⁿ',
'i' => 'ⁱ',
_ => return None,
})
}
fn sub_char(c: char) -> Option<char> {
Some(match c {
'0'..='9' => ['₀', '₁', '₂', '₃', '₄', '₅', '₆', '₇', '₈', '₉'][c as usize - '0' as usize],
'+' => '₊',
'-' => '₋',
'=' => '₌',
'(' => '₍',
')' => '₎',
_ => return None,
})
}
fn map_all_or_keep(inner: &str, f: impl Fn(char) -> Option<char>) -> String {
match inner.chars().map(f).collect::<Option<String>>() {
Some(s) => s,
None => inner.to_string(),
}
}
fn replace_tag_pair(s: &str, tag: &str, f: impl Fn(&str) -> String) -> String {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let mut out = String::new();
let mut rest = s;
while let Some(o) = rest.find(&open) {
let after = o + open.len();
let Some(c) = rest[after..].find(&close) else {
break;
};
out.push_str(&rest[..o]);
out.push_str(&f(&rest[after..after + c]));
rest = &rest[after + c + close.len()..];
}
out.push_str(rest);
out
}
pub fn process_inline_html(src: &str) -> String {
let mut out = String::new();
let mut in_fence = false;
for line in src.lines() {
let t = line.trim_start();
if t.starts_with("```") || t.starts_with("~~~") {
in_fence = !in_fence;
out.push_str(line);
out.push('\n');
continue;
}
if in_fence || !line.contains('<') {
out.push_str(line);
out.push('\n');
continue;
}
let mut s = line.to_string();
s = replace_tag_pair(&s, "kbd", |i| format!("`{i}`"));
s = replace_tag_pair(&s, "del", |i| format!("~~{i}~~"));
s = replace_tag_pair(&s, "s", |i| format!("~~{i}~~"));
s = replace_tag_pair(&s, "strike", |i| format!("~~{i}~~"));
s = replace_tag_pair(&s, "sup", |i| map_all_or_keep(i, sup_char));
s = replace_tag_pair(&s, "sub", |i| map_all_or_keep(i, sub_char));
for br in ["<br>", "<br/>", "<br />", "<BR>", "<BR/>", "<BR />"] {
s = s.replace(br, " \n");
}
out.push_str(&s);
out.push('\n');
}
out
}
fn to_superscript(n: usize) -> String {
const SUP: [char; 10] = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
n.to_string()
.chars()
.map(|c| SUP[c.to_digit(10).unwrap_or(0) as usize])
.collect()
}
fn parse_footnote_def(line: &str) -> Option<(String, String)> {
let t = line.trim_start();
let rest = t.strip_prefix("[^")?;
let close = rest.find(']')?;
let id = &rest[..close];
if id.is_empty() || id.contains('[') {
return None;
}
let after = rest[close + 1..].strip_prefix(':')?;
Some((id.to_string(), after.trim().to_string()))
}
fn find_footnote_refs(line: &str) -> Vec<String> {
let mut ids = Vec::new();
let mut i = 0;
while i < line.len() {
if line[i..].starts_with("[^") {
if let Some(close) = line[i + 2..].find(']') {
let id = &line[i + 2..i + 2 + close];
if !id.is_empty() && !id.contains('[') {
ids.push(id.to_string());
i += 2 + close + 1;
continue;
}
}
}
i += line[i..].chars().next().map_or(1, char::len_utf8);
}
ids
}
fn replace_footnote_refs(line: &str, num: &std::collections::HashMap<String, usize>) -> String {
let mut out = String::new();
let mut i = 0;
while i < line.len() {
if line[i..].starts_with("[^") {
if let Some(close) = line[i + 2..].find(']') {
let id = &line[i + 2..i + 2 + close];
if !id.is_empty() && !id.contains('[') {
if let Some(&n) = num.get(id) {
out.push_str(&to_superscript(n));
i += 2 + close + 1;
continue;
}
}
}
}
let ch_len = line[i..].chars().next().map_or(1, char::len_utf8);
out.push_str(&line[i..i + ch_len]);
i += ch_len;
}
out
}
pub fn process_footnotes(src: &str) -> String {
use std::collections::HashMap;
let lines: Vec<&str> = src.lines().collect();
let mut defs: Vec<(String, String)> = Vec::new();
let mut is_def = vec![false; lines.len()];
let mut in_fence = false;
for (i, line) in lines.iter().enumerate() {
let t = line.trim_start();
if t.starts_with("```") || t.starts_with("~~~") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
if let Some((id, text)) = parse_footnote_def(line) {
defs.push((id, text));
is_def[i] = true;
}
}
if defs.is_empty() {
return src.to_string();
}
let def_ids: std::collections::HashSet<&str> = defs.iter().map(|(id, _)| id.as_str()).collect();
let mut num: HashMap<String, usize> = HashMap::new();
let mut next = 1usize;
in_fence = false;
for (i, line) in lines.iter().enumerate() {
let t = line.trim_start();
if t.starts_with("```") || t.starts_with("~~~") {
in_fence = !in_fence;
continue;
}
if in_fence || is_def[i] {
continue;
}
for id in find_footnote_refs(line) {
if def_ids.contains(id.as_str()) && !num.contains_key(&id) {
num.insert(id, next);
next += 1;
}
}
}
if num.is_empty() {
return src.to_string(); }
let mut out = String::new();
in_fence = false;
for (i, line) in lines.iter().enumerate() {
let t = line.trim_start();
if t.starts_with("```") || t.starts_with("~~~") {
in_fence = !in_fence;
out.push_str(line);
out.push('\n');
continue;
}
if in_fence {
out.push_str(line);
out.push('\n');
continue;
}
if is_def[i] {
continue;
}
out.push_str(&replace_footnote_refs(line, &num));
out.push('\n');
}
let mut items: Vec<(usize, &str)> = num
.iter()
.map(|(id, &n)| {
let text = defs
.iter()
.find(|(did, _)| did == id)
.map(|(_, t)| t.as_str())
.unwrap_or("");
(n, text)
})
.collect();
items.sort_by_key(|(n, _)| *n);
out.push_str("\n---\n\n");
for (n, text) in items {
out.push_str(&format!("{n}. {text}\n"));
}
out
}
pub fn strip_front_matter(src: &str) -> (Option<String>, String) {
let lines: Vec<&str> = src.lines().collect();
if lines.first().map(|l| l.trim_end()) != Some("---") {
return (None, src.to_string());
}
let Some(rel) = lines[1..]
.iter()
.position(|l| matches!(l.trim_end(), "---" | "..."))
else {
return (None, src.to_string()); };
let close = rel + 1;
let inner = lines[1..close].join("\n");
let body = lines[close + 1..].join("\n");
(Some(inner), body)
}
pub fn render_front_matter(inner: &str, width: u16) -> Vec<Line<'static>> {
let key_style = Style::new().fg(Color::Cyan).add_modifier(Modifier::DIM);
let dim = Style::new().add_modifier(Modifier::DIM);
let mut out = Vec::new();
for raw in inner.lines() {
let line = raw.trim_end();
if line.trim().is_empty() {
out.push(Line::from(String::new()));
continue;
}
if !line.starts_with([' ', '\t']) {
if let Some(colon) = line.find(':') {
if colon > 0 {
return_split_key(&mut out, line, colon, key_style, dim);
continue;
}
}
}
out.push(Line::from(Span::styled(line.to_string(), dim)));
}
out.push(Line::from(Span::styled(
"─".repeat(width as usize),
Style::new().fg(TABLE_BORDER_FG),
)));
out.push(Line::from(String::new()));
out
}
fn return_split_key(
out: &mut Vec<Line<'static>>,
line: &str,
colon: usize,
key_style: Style,
dim: Style,
) {
let (k, v) = line.split_at(colon); out.push(Line::from(vec![
Span::styled(k.to_string(), key_style),
Span::styled(v.to_string(), dim),
]));
}
fn is_table_delimiter(line: &str) -> bool {
let t = line.trim();
if !t.contains('-') || !t.contains('|') {
return false;
}
t.chars().all(|c| matches!(c, ' ' | '\t' | '-' | ':' | '|'))
}
fn looks_like_table_row(line: &str) -> bool {
line.contains('|') && !line.trim().is_empty()
}
fn split_tables(md: &str) -> Vec<MdPart> {
let lines: Vec<&str> = md.lines().collect();
let mut parts = Vec::new();
let mut text = String::new();
let mut i = 0;
while i < lines.len() {
if i + 1 < lines.len() && looks_like_table_row(lines[i]) && is_table_delimiter(lines[i + 1])
{
if !text.is_empty() {
parts.push(MdPart::Text(std::mem::take(&mut text)));
}
let mut raw = String::new();
raw.push_str(lines[i]);
raw.push('\n');
raw.push_str(lines[i + 1]);
raw.push('\n');
let mut j = i + 2;
while j < lines.len() && looks_like_table_row(lines[j]) {
raw.push_str(lines[j]);
raw.push('\n');
j += 1;
}
parts.push(MdPart::Table(raw));
i = j;
} else {
text.push_str(lines[i]);
text.push('\n');
i += 1;
}
}
if !text.is_empty() {
parts.push(MdPart::Text(text));
}
parts
}
fn parse_table_row(line: &str) -> Vec<String> {
let t = line.trim();
let t = t.strip_prefix('|').unwrap_or(t);
let t = if t.ends_with('|') && !t.ends_with("\\|") {
&t[..t.len() - 1]
} else {
t
};
let mut cells = Vec::new();
let mut cur = String::new();
let mut chars = t.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\\' if chars.peek() == Some(&'|') => {
cur.push('|');
chars.next();
}
'|' => cells.push(std::mem::take(&mut cur)),
_ => cur.push(c),
}
}
cells.push(cur);
cells.into_iter().map(|c| c.trim().to_string()).collect()
}
#[derive(Clone, Copy, PartialEq)]
enum ColAlign {
Left,
Center,
Right,
}
fn parse_table_aligns(line: &str) -> Vec<ColAlign> {
parse_table_row(line)
.iter()
.map(|c| {
let l = c.starts_with(':');
let r = c.ends_with(':');
match (l, r) {
(true, true) => ColAlign::Center,
(false, true) => ColAlign::Right,
_ => ColAlign::Left,
}
})
.collect()
}
pub fn link_label_style() -> Style {
Style::new()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED)
}
pub fn hidden_link_target_style() -> Style {
link_label_style().add_modifier(Modifier::HIDDEN)
}
pub fn is_hidden_link_target(span: &Span<'_>) -> bool {
span.style.add_modifier.contains(Modifier::HIDDEN)
&& span.style.add_modifier.contains(Modifier::UNDERLINED)
&& span.style.fg == Some(Color::Blue)
}
#[derive(Clone)]
enum CellSeg {
Text { text: String, style: Style },
Link { label: String, url: String },
}
impl CellSeg {
fn plain(text: String) -> CellSeg {
CellSeg::Text {
text,
style: Style::new(),
}
}
}
fn seg_width(seg: &CellSeg) -> usize {
match seg {
CellSeg::Text { text, .. } => UnicodeWidthStr::width(text.as_str()),
CellSeg::Link { label, .. } => UnicodeWidthStr::width(label.as_str()),
}
}
fn try_inline_styled(rest: &str) -> Option<(usize, CellSeg)> {
const MARKERS: &[&str] = &["***", "**", "*", "~~", "`"];
for open in MARKERS {
let Some(r) = rest.strip_prefix(open) else {
continue;
};
let Some(end) = r.find(open) else {
continue;
};
if end == 0 {
continue; }
let inner = &r[..end];
if *open != "`"
&& (inner.starts_with(char::is_whitespace) || inner.ends_with(char::is_whitespace))
{
continue; }
let style = match *open {
"***" => Style::new().add_modifier(Modifier::BOLD | Modifier::ITALIC),
"**" => Style::new().add_modifier(Modifier::BOLD),
"*" => Style::new().add_modifier(Modifier::ITALIC),
"~~" => Style::new().add_modifier(Modifier::CROSSED_OUT),
"`" => Style::new().fg(Color::White),
_ => unreachable!(),
};
return Some((
open.len() + end + open.len(),
CellSeg::Text {
text: inner.to_string(),
style,
},
));
}
None
}
fn segs_width(segs: &[CellSeg]) -> usize {
segs.iter().map(seg_width).sum()
}
fn parse_cell_segments(cell: &str) -> Vec<CellSeg> {
let mut out = Vec::new();
let mut text = String::new();
let mut i = 0;
while i < cell.len() {
let rest = &cell[i..];
if let Some((consumed, seg)) = try_inline_styled(rest) {
if !text.is_empty() {
out.push(CellSeg::plain(std::mem::take(&mut text)));
}
out.push(seg);
i += consumed;
continue;
}
if rest.starts_with('[') && !text.ends_with('!') {
if let Some(close) = rest.find(']') {
let after = &rest[close + 1..];
if let Some(url_rest) = after.strip_prefix('(') {
if let Some(par) = url_rest.find(')') {
let label = &rest[1..close];
let url = strip_link_destination(&url_rest[..par]);
let url = url.as_str();
if !label.is_empty() && !url.is_empty() {
if !text.is_empty() {
out.push(CellSeg::plain(std::mem::take(&mut text)));
}
out.push(CellSeg::Link {
label: label.to_string(),
url: url.to_string(),
});
i += close + 2 + par + 1;
continue;
}
}
}
}
}
let ch = rest.chars().next().expect("non-empty rest");
text.push(ch);
i += ch.len_utf8();
}
if !text.is_empty() {
out.push(CellSeg::plain(text));
}
out
}
fn strip_link_destination(dest: &str) -> String {
let d = dest.trim();
if let Some(inner) = d.strip_prefix('<').and_then(|x| x.strip_suffix('>')) {
return inner.trim().to_string();
}
if let Some(sp) = d.find(char::is_whitespace) {
let (u, rest) = d.split_at(sp);
let rest = rest.trim();
let quoted = rest.len() >= 2
&& ((rest.starts_with('"') && rest.ends_with('"'))
|| (rest.starts_with('\'') && rest.ends_with('\'')));
if quoted {
return u.to_string();
}
}
d.to_string()
}
fn truncate_to_width(s: &str, w: usize) -> String {
let mut out = String::new();
let mut used = 0usize;
for ch in s.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(1);
if used + cw > w {
break;
}
out.push(ch);
used += cw;
}
out
}
fn wrap_segments(segs: &[CellSeg], w: usize) -> Vec<Vec<CellSeg>> {
if w == 0 || segs_width(segs) <= w {
return vec![segs.to_vec()];
}
let mut lines: Vec<Vec<CellSeg>> = Vec::new();
let mut cur: Vec<CellSeg> = Vec::new();
let mut cur_w = 0usize;
for seg in segs {
match seg {
CellSeg::Text { text, style } => {
let mut buf = String::new();
for ch in text.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(1);
if cur_w + cw > w && cur_w > 0 {
if !buf.is_empty() {
cur.push(CellSeg::Text {
text: std::mem::take(&mut buf),
style: *style,
});
}
lines.push(std::mem::take(&mut cur));
cur_w = 0;
}
buf.push(ch);
cur_w += cw;
}
if !buf.is_empty() {
cur.push(CellSeg::Text {
text: buf,
style: *style,
});
}
}
CellSeg::Link { label, url } => {
let lw = UnicodeWidthStr::width(label.as_str());
if cur_w + lw > w && cur_w > 0 {
lines.push(std::mem::take(&mut cur));
cur_w = 0;
}
let label = if lw > w {
truncate_to_width(label, w)
} else {
label.clone()
};
cur_w += UnicodeWidthStr::width(label.as_str());
cur.push(CellSeg::Link {
label,
url: url.clone(),
});
}
}
}
if !cur.is_empty() {
lines.push(cur);
}
if lines.is_empty() {
lines.push(Vec::new());
}
lines
}
fn render_table(raw: &str, width: u16, icons: bool) -> Vec<Line<'static>> {
let mut rows: Vec<Vec<Vec<CellSeg>>> = Vec::new();
let mut header_rows = 0usize; let mut aligns: Vec<ColAlign> = Vec::new();
for line in raw.lines() {
if is_table_delimiter(line) {
header_rows = rows.len();
aligns = parse_table_aligns(line); continue;
}
rows.push(
parse_table_row(line)
.into_iter()
.map(|c| {
let mut segs = parse_cell_segments(&c);
if icons {
for seg in &mut segs {
if let CellSeg::Link { label, .. } = seg {
*label = format!("{} {label}", crate::ui::icons::link_icon());
}
}
}
segs
})
.collect(),
);
}
let ncol = rows.iter().map(|r| r.len()).max().unwrap_or(0);
if rows.is_empty() || ncol == 0 {
return Vec::new();
}
for r in &mut rows {
r.resize(ncol, Vec::new());
}
let mut col_w = vec![1usize; ncol];
for r in &rows {
for (c, cell) in r.iter().enumerate() {
col_w[c] = col_w[c].max(segs_width(cell));
}
}
let frame = (ncol + 1) + 2 * ncol;
let budget = (width as usize).saturating_sub(frame).max(ncol);
let mut total: usize = col_w.iter().sum();
while total > budget {
let (mi, &mw) = col_w.iter().enumerate().max_by_key(|(_, &w)| w).unwrap();
if mw <= 1 {
break;
}
col_w[mi] -= 1;
total -= 1;
}
let border = Style::new().fg(TABLE_BORDER_FG);
let rule = |left: char, mid: char, right: char| -> Line<'static> {
let mut s = String::new();
s.push(left);
for (c, w) in col_w.iter().enumerate() {
for _ in 0..(w + 2) {
s.push('─');
}
s.push(if c + 1 == ncol { right } else { mid });
}
Line::from(Span::styled(s, border))
};
let mut out = Vec::new();
out.push(rule('┌', '┬', '┐'));
for (ri, r) in rows.iter().enumerate() {
let is_head = ri < header_rows;
let wrapped: Vec<Vec<Vec<CellSeg>>> = r
.iter()
.enumerate()
.map(|(c, cell)| wrap_segments(cell, col_w[c]))
.collect();
let phys = wrapped.iter().map(|w| w.len().max(1)).max().unwrap_or(1);
let cell_style = if is_head {
Style::new().fg(HEAD_FG).add_modifier(Modifier::BOLD)
} else {
Style::new()
};
for p in 0..phys {
let mut spans: Vec<Span<'static>> = vec![Span::styled("│", border)];
for c in 0..ncol {
let segs: &[CellSeg] = wrapped[c].get(p).map(|v| v.as_slice()).unwrap_or(&[]);
let pad = col_w[c].saturating_sub(segs_width(segs));
let (lp, rp) = match aligns.get(c).copied().unwrap_or(ColAlign::Left) {
ColAlign::Left => (0, pad),
ColAlign::Right => (pad, 0),
ColAlign::Center => (pad / 2, pad - pad / 2),
};
spans.push(Span::styled(format!(" {}", " ".repeat(lp)), cell_style));
for seg in segs {
match seg {
CellSeg::Text { text, style } => {
spans.push(Span::styled(text.clone(), cell_style.patch(*style)))
}
CellSeg::Link { label, url } => {
spans.push(Span::styled(label.clone(), link_label_style()));
spans.push(Span::styled(url.clone(), hidden_link_target_style()));
}
}
}
spans.push(Span::styled(format!("{} ", " ".repeat(rp)), cell_style));
spans.push(Span::styled("│", border));
}
out.push(Line::from(spans));
}
if header_rows > 0 && ri + 1 == header_rows {
out.push(rule('├', '┼', '┤'));
}
}
out.push(rule('└', '┴', '┘'));
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::DEFAULT_CODE_BG;
const BG: CodeStyle = CodeStyle {
bg: Some(DEFAULT_CODE_BG),
label_bg: Some(Color::Rgb(70, 78, 99)), label_right: true,
tab_width: 4,
wrap: true,
};
const NO_CODE: CodeStyle = CodeStyle {
bg: None,
label_bg: None,
label_right: true,
tab_width: 4,
wrap: true,
};
fn line_disp_width(l: &Line<'_>) -> usize {
let s: String = l.spans.iter().map(|sp| sp.content.as_ref()).collect();
UnicodeWidthStr::width(s.as_str())
}
#[test]
fn code_block_tabs_expand_to_marker() {
let md = "```ts\nfunction f() {\n\tconst x = 1;\n}\n```\n";
let lines = render_markdown(md, 40, BG, "TwoDark", false);
let texts: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
let tab_line = texts
.iter()
.find(|t| t.contains("const x"))
.expect("コード行が無い");
assert!(
tab_line.contains('→'),
"タブが可視化されていない: {tab_line:?}"
);
assert!(!tab_line.contains('\t'), "生タブが残っている: {tab_line:?}");
assert!(
tab_line.starts_with("▎ →"),
"ガター+マーカーの並びが違う: {tab_line:?}"
);
let marker_lines = texts.iter().filter(|t| t.contains('→')).count();
assert_eq!(marker_lines, 1, "マーカー行数が想定外: {marker_lines}");
}
#[test]
fn table_cell_link_renders_label_with_hidden_target() {
let md = "| name | doc |\n|---|---|\n| konoma | [Docs](./docs/readme.md) |\n";
let lines = render_markdown(md, 60, BG, "TwoDark", false);
let row = lines
.iter()
.find(|l| l.spans.iter().any(|sp| sp.content.as_ref() == "Docs"))
.expect("リンクセルの行が無い");
let joined: String = row.spans.iter().map(|sp| sp.content.as_ref()).collect();
assert!(
!joined.contains("[Docs]"),
"生の Markdown 記法が残っている: {joined:?}"
);
let label = row
.spans
.iter()
.find(|sp| sp.content.as_ref() == "Docs")
.unwrap();
assert_eq!(label.style.fg, Some(Color::Blue));
assert!(label.style.add_modifier.contains(Modifier::UNDERLINED));
assert!(!label.style.add_modifier.contains(Modifier::HIDDEN));
let li = row
.spans
.iter()
.position(|sp| sp.content.as_ref() == "Docs")
.unwrap();
let hidden = &row.spans[li + 1];
assert_eq!(hidden.content.as_ref(), "./docs/readme.md");
assert!(is_hidden_link_target(hidden), "URL は HIDDEN の隠しスパン");
}
#[test]
fn table_link_rows_align_after_hiding_targets() {
let md = "| name | doc |\n|---|---|\n| konoma | [Docs](./docs/readme.md) |\n| plain | text cell |\n";
let lines = render_markdown(md, 60, BG, "TwoDark", false);
let widths: Vec<usize> = lines
.iter()
.map(|l| {
l.spans
.iter()
.filter(|sp| !is_hidden_link_target(sp))
.map(|sp| UnicodeWidthStr::width(sp.content.as_ref()))
.sum()
})
.collect();
assert!(!widths.is_empty());
assert!(
widths.iter().all(|w| *w == widths[0]),
"行の表示幅が揃わない: {widths:?}"
);
}
#[test]
fn table_link_wraps_atomically_in_narrow_width() {
let md = "| doc |\n|---|\n| intro text [Guide](./guide.md) tail |\n";
let lines = render_markdown(md, 18, BG, "TwoDark", false);
let mut found = false;
for l in &lines {
if let Some(i) = l.spans.iter().position(|sp| sp.content.as_ref() == "Guide") {
assert!(is_hidden_link_target(&l.spans[i + 1]));
found = true;
}
}
assert!(found, "折返し後もリンクラベルが1スパンで残る");
let widths: Vec<usize> = lines
.iter()
.map(|l| {
l.spans
.iter()
.filter(|sp| !is_hidden_link_target(sp))
.map(|sp| UnicodeWidthStr::width(sp.content.as_ref()))
.sum()
})
.collect();
assert!(widths.iter().all(|w| *w == widths[0]), "{widths:?}");
}
#[test]
fn cell_segments_parse_links_and_leave_images_as_text() {
let segs = parse_cell_segments("see [a](b.md) end");
assert_eq!(segs.len(), 3);
assert!(matches!(&segs[1], CellSeg::Link { label, url } if label == "a" && url == "b.md"));
let img = parse_cell_segments("");
assert!(matches!(&img[..], [CellSeg::Text { text, .. }] if text == ""));
let broken = parse_cell_segments("[no url] and [y](");
assert!(broken.iter().all(|s| matches!(s, CellSeg::Text { .. })));
let titled = parse_cell_segments("[t](./g.md \"Title\")");
assert!(
matches!(&titled[..], [CellSeg::Link { url, .. }] if url == "./g.md"),
"title がリンク先に混入しない"
);
let angled = parse_cell_segments("[t](<./with space.md>)");
assert!(
matches!(&angled[..], [CellSeg::Link { url, .. }] if url == "./with space.md"),
"<> 囲みは中身だけ"
);
}
#[test]
fn table_link_icon_matches_paragraph_links_and_keeps_alignment() {
let md = "| a | b |\n|---|---|\n| [Docs](./g.md) | plain |\n";
let lines = render_markdown(md, 60, BG, "TwoDark", true);
let row = lines
.iter()
.find(|l| {
l.spans
.iter()
.any(|sp| sp.content.as_ref().contains("Docs"))
})
.expect("リンク行");
let icon = crate::ui::icons::link_icon();
let label = row
.spans
.iter()
.find(|sp| sp.content.as_ref().contains("Docs"))
.unwrap();
assert!(
label.content.as_ref().starts_with(&format!("{icon} ")),
"アイコンが前置される: {:?}",
label.content
);
let widths: Vec<usize> = lines
.iter()
.map(|l| {
l.spans
.iter()
.filter(|sp| !is_hidden_link_target(sp))
.map(|sp| UnicodeWidthStr::width(sp.content.as_ref()))
.sum()
})
.collect();
assert!(widths.iter().all(|w| *w == widths[0]), "{widths:?}");
}
#[test]
fn table_escaped_pipe_stays_in_one_cell() {
let md = "| a | b |\n|---|---|\n| x \\| y | z |\n";
let lines = render_markdown(md, 60, BG, "TwoDark", false);
let row: String = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|sp| sp.content.as_ref())
.collect::<String>()
})
.find(|t| t.contains("x | y"))
.expect("エスケープパイプがリテラルで残る");
assert_eq!(
row.matches('│').count(),
3,
"2列のまま(幽霊列なし): {row:?}"
);
}
#[test]
fn table_alignment_colons_are_respected() {
let md = "| xxxx | yyyy | zzzz |\n|:-----|:----:|-----:|\n| a | b | c |\n";
let lines = render_markdown(md, 60, BG, "TwoDark", false);
let row: String = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|sp| sp.content.as_ref())
.collect::<String>()
})
.find(|t| t.contains(" a ") && t.contains('│'))
.expect("データ行");
assert_eq!(row, "│ a │ b │ c │", "左/中央/右の整列: {row:?}");
}
#[test]
fn table_cell_inline_styles_render_without_markers() {
let md = "| a |\n|---|\n| **b** and *i* and `c` and ~~s~~ |\n";
let lines = render_markdown(md, 60, BG, "TwoDark", false);
let row = lines
.iter()
.find(|l| l.spans.iter().any(|sp| sp.content.as_ref() == "b"))
.expect("スタイルセル行");
let joined: String = row.spans.iter().map(|sp| sp.content.as_ref()).collect();
assert!(
!joined.contains('*') && !joined.contains('`') && !joined.contains('~'),
"生の記号が残っている: {joined:?}"
);
let has = |txt: &str, m: Modifier| {
row.spans
.iter()
.any(|sp| sp.content.as_ref() == txt && sp.style.add_modifier.contains(m))
};
assert!(has("b", Modifier::BOLD), "bold");
assert!(has("i", Modifier::ITALIC), "italic");
assert!(has("s", Modifier::CROSSED_OUT), "strike");
assert!(
row.spans
.iter()
.any(|sp| sp.content.as_ref() == "c" && sp.style.fg == Some(Color::White)),
"code fg"
);
let plain = parse_cell_segments("2 * 3 * 4");
assert!(matches!(&plain[..], [CellSeg::Text { text, .. }] if text == "2 * 3 * 4"));
}
#[test]
fn details_open_tag_and_split_details() {
assert_eq!(details_open_tag("<details>"), Some(false));
assert_eq!(details_open_tag("<details open>"), Some(true));
assert_eq!(details_open_tag(" <DETAILS OPEN>"), Some(true));
assert_eq!(details_open_tag("<detailsx>"), None);
assert_eq!(details_open_tag("plain"), None);
let md =
"intro\n\n<details open>\n<summary>Sum</summary>\n\nbody line\n\n</details>\n\ntail\n";
let parts = split_details(md);
assert_eq!(parts.len(), 3, "Text / Details / Text");
match &parts[1] {
DetailsPart::Details {
open_attr,
summary,
body,
} => {
assert!(*open_attr);
assert_eq!(summary, "Sum");
assert!(body.contains("body line"));
}
_ => panic!("expected a details part"),
}
let fenced = "```\n<details>\n<summary>x</summary>\n</details>\n```\n";
assert!(split_details(fenced)
.iter()
.all(|p| matches!(p, DetailsPart::Text(_))));
}
#[test]
fn details_config_modes_force_open_or_closed() {
let md = "<details>\n<summary>Sum</summary>\n\nthe body\n\n</details>\n";
let shown = |states: Vec<bool>| -> bool {
set_details_open(states);
let lines =
render_markdown_tasks_opts(md, 60, BG, "TwoDark", false, DEFAULT_TASK_STATES, true);
let all: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
all.contains("the body")
};
assert!(shown(vec![true]), "forced open → body shown");
assert!(!shown(vec![false]), "forced closed → body hidden");
}
#[test]
fn math_inline_and_display_detection() {
let src = "before $x^2$ mid \\(a+b\\) end\n\n$$E = mc^2$$\n\n\\[ \\int f \\]\n\ntail\n";
let m = collect_math_exprs(src);
assert_eq!(
m,
vec![
("x^2".to_string(), false),
("a+b".to_string(), false),
("E = mc^2".to_string(), true),
("\\int f".to_string(), true),
]
);
let ml = collect_math_exprs("$$\n\\sum_{i} i\n$$\n");
assert_eq!(ml, vec![("\\sum_{i} i".to_string(), true)]);
}
#[test]
fn math_currency_and_code_are_not_mistaken() {
assert!(collect_math_exprs("it costs $5 and $10 total\n").is_empty());
assert!(collect_math_exprs("give me $ 5 $ please\n").is_empty()); assert!(collect_math_exprs("use `$x$` inline\n").is_empty());
assert!(collect_math_exprs("```\n$x^2$\n```\n").is_empty());
assert!(collect_math_exprs("cost \\$5 and \\$10\n").is_empty());
}
#[test]
fn math_cjk_and_url_key_are_safe() {
let m = collect_math_exprs("質量エネルギー $E=mc^2$ と水\n");
assert_eq!(m, vec![("E=mc^2".to_string(), false)]);
for s in [
"\\あ",
"価格は\\円です\n",
"path C:\\ユーザー\\x done \\🎉\n",
"$a\\あ$ and text\n", "\\", ] {
let _ = collect_math_exprs(s); }
assert_ne!(math_url("x^2", true), math_url("x^2", false));
assert!(is_math_url(&math_url("x", false)));
assert!(!is_math_url("mermaid-fence://abc"));
}
#[test]
fn math_renders_image_placeholder_or_raw_fallback() {
let img_slot = |_: &str, _: bool| MathSlot::Image { cols: 8, rows: 2 };
let (_lines, imgs) = render_markdown_with_images(
"text $x^2$ more\n",
40,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_: &str| ImageSlot::Unavailable,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&img_slot,
true,
);
assert_eq!(imgs.len(), 1, "one math placement");
assert!(is_math_url(&imgs[0].url));
let (raw_lines, raw_imgs) = render_markdown_with_images(
"text $x^2$ more\n",
40,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_: &str| ImageSlot::Unavailable,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Raw,
true,
);
assert!(raw_imgs.is_empty());
let joined: String = raw_lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(joined.contains("$x^2$"), "raw LaTeX shown: {joined:?}");
let (_l, off_imgs) = render_markdown_with_images(
"text $x^2$ more\n",
40,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_: &str| ImageSlot::Unavailable,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
assert!(off_imgs.is_empty(), "math off: no placements");
}
#[test]
fn malformed_summary_tag_does_not_panic() {
for md in [
"<details>\n<summary attr\n</summary>\nbody\n</details>\n",
"<details>\n<summary bad</summary>tail\n</details>\n",
"<details>\n<summary>日本語 attr\n</summary>\n本文\n</details>\n",
] {
let _ = render_markdown(md, 40, BG, "TwoDark", false); }
let (sum, body) = extract_summary_body("<summary attr\nbody");
assert_eq!(sum, "");
assert!(body.contains("body"));
}
#[test]
fn nested_details_do_not_drift_later_block_open_state() {
let md = "<details>\n<summary>A</summary>\n<details>\n<summary>Nested</summary>\n</details>\n</details>\n\n<details open>\n<summary>C</summary>\nc body\n</details>\n";
assert_eq!(
collect_details_open(md),
vec![false, true],
"top-level only: outer(closed) + C(open)"
);
set_details_open(collect_details_open(md));
let lines =
render_markdown_tasks_opts(md, 60, BG, "TwoDark", false, DEFAULT_TASK_STATES, true);
let c_arrow = lines.iter().find_map(|l| {
let joined: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
joined
.contains(" C")
.then(|| joined.chars().next().unwrap_or(' '))
});
assert_eq!(
c_arrow,
Some('▾'),
"C honors <details open> → expanded marker"
);
}
#[test]
fn html_block_text_survives_and_autolink_untouched() {
let md = "before\n\n<details>\n<summary>Summary text</summary>\nhidden body\n</details>\n\n<details open>\n<summary>Open Summary</summary>\nvisible body\n</details>\n\n<!-- secret comment -->\n\nsee <https://ratatui.rs> end\n";
let lines = render_markdown(md, 60, BG, "TwoDark", false);
let all: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|sp| sp.content.as_ref()).collect())
.collect();
assert!(
all.iter().any(|t| t.contains("Summary text")),
"折りたたみ summary は残る: {all:?}"
);
assert!(
all.iter().all(|t| !t.contains("hidden body")),
"折りたたみ既定で本文は隠れる: {all:?}"
);
assert!(all.iter().any(|t| t.contains("Open Summary")));
assert!(
all.iter().any(|t| t.contains("visible body")),
"open は展開して本文が出る: {all:?}"
);
assert!(
all.iter().all(|t| !t.contains('<')),
"タグは剥がす: {all:?}"
);
assert!(
all.iter().all(|t| !t.contains("secret")),
"コメントは非表示"
);
assert!(
all.iter().any(|t| t.contains("https://ratatui.rs")),
"autolink は生きる"
);
}
#[test]
fn thematic_break_and_task_checkboxes_decorate() {
let md = "para\n\n---\n\n- [ ] open task\n- [x] done task\n";
let lines = render_markdown(md, 40, BG, "TwoDark", false);
let all: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|sp| sp.content.as_ref()).collect())
.collect();
assert!(
all.iter().any(|t| t.trim() == "─".repeat(40)),
"--- が全幅罫線になる: {all:?}"
);
assert!(
all.iter().any(|t| t.contains("[ ] open task")),
"未完 [ ]: {all:?}"
);
assert!(all.iter().any(|t| t.contains("[x] done task")), "完了 [x]");
let markers: Vec<String> = lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|s| is_task_span(s))
.map(|s| s.content.to_string())
.collect();
assert_eq!(markers, vec!["[ ] ", "[x] "], "マーカーは末尾スペース込み");
}
#[test]
fn code_block_wrap_keeps_gutter_on_every_row() {
use unicode_width::UnicodeWidthStr;
let long = "abcdefghij".repeat(6); let md = format!("```\n{long}\nshort\n```\n");
let lines = render_markdown(&md, 30, BG, "TwoDark", false);
let code_rows: Vec<&Line> = lines
.iter()
.filter(|l| l.spans.first().is_some_and(|s| s.content.starts_with('▎')))
.collect();
assert_eq!(code_rows.len(), 6, "{:?}", code_rows.len());
for l in &code_rows {
let w: usize = l.spans.iter().map(|s| s.content.as_ref().width()).sum();
assert!(w <= 30, "行幅が枠を超えない: {w}");
}
let joined: String = code_rows
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref().trim_start_matches("▎ "))
.collect::<String>()
.replace(' ', "");
assert!(joined.contains(&long), "折返しで文字が欠けない");
let cjk = "あ".repeat(20);
let md = format!("```\n{cjk}\n```\n");
let lines = render_markdown(&md, 30, BG, "TwoDark", false);
let rows: Vec<&Line> = lines
.iter()
.filter(|l| l.spans.first().is_some_and(|s| s.content.starts_with('▎')))
.collect();
assert_eq!(rows.len(), 4, "バッジ+全角14文字+6文字+終端パディング");
for l in &rows {
let w: usize = l.spans.iter().map(|s| s.content.as_ref().width()).sum();
assert!(w <= 30, "CJK でも行幅が枠内: {w}");
}
let nowrap = CodeStyle { wrap: false, ..BG };
let md = format!("```\n{long}\n```\n");
let lines = render_markdown_tasks(&md, 30, nowrap, "TwoDark", false, DEFAULT_TASK_STATES);
let rows: Vec<&Line> = lines
.iter()
.filter(|l| l.spans.first().is_some_and(|s| s.content.starts_with('▎')))
.collect();
assert_eq!(
rows.len(),
3,
"バッジ+本文1行+終端パディングのみ(分割しない)"
);
let w0: usize = rows[1]
.spans
.iter()
.map(|s| s.content.as_ref().width())
.sum();
assert!(w0 > 30, "wrap=false は長い行を保つ(h スクロールで読む)");
}
#[test]
fn loose_list_task_item_does_not_panic() {
let md = "# title\n\n- a\n\n- [ ] b\n\n**bold**\n";
let lines = render_markdown(md, 60, BG, "TwoDark", false);
let all: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert!(
all.iter().any(|t| t.contains("- a")),
"内容は読める形で残る: {all:?}"
);
let markers: Vec<String> = lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|s| is_task_span(s))
.map(|s| s.content.to_string())
.collect();
assert_eq!(markers, vec!["[ ] "], "タスクが装飾のまま: {all:?}");
assert!(
all.iter().any(|t| t.contains("bold") && !t.contains("**")),
"bold が描画される: {all:?}"
);
assert!(
all.iter().all(|t| !t.contains("# title")),
"見出しの # が剥がれる: {all:?}"
);
}
#[test]
fn split_block_for_retry_avoids_fences() {
let src = "para1\n\n```\ncode\n\nmore\n```\n\npara2\n";
let (a, b) = split_block_for_retry(src).expect("分割できる");
assert!(
a.matches("```").count() % 2 == 0,
"前半のフェンスは閉じている: {a:?}"
);
assert!(
b.matches("```").count() % 2 == 0,
"後半のフェンスは閉じている: {b:?}"
);
assert!(split_block_for_retry("only-one-line").is_none());
}
#[test]
fn task_markers_become_dedicated_spans_with_custom_states() {
let md = "- [ ] open\n- [x] done\n- [/] doing\n";
let dflt = render_markdown(md, 40, BG, "TwoDark", false);
let markers = |lines: &[Line<'static>]| -> Vec<String> {
lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|s| is_task_span(s))
.map(|s| s.content.to_string())
.collect()
};
assert_eq!(markers(&dflt), vec!["[ ] ", "[x] "], "既定では / は対象外");
let custom = render_markdown_tasks(md, 40, BG, "TwoDark", false, &[' ', '/', 'x']);
assert_eq!(markers(&custom), vec!["[ ] ", "[x] ", "[/] "]);
let nf_off = format!("{} ", crate::ui::icons::task_icon(false));
let nf_on = format!("{} ", crate::ui::icons::task_icon(true));
let iconed = render_markdown_tasks(md, 40, BG, "TwoDark", true, &[' ', '/', 'x']);
assert_eq!(
markers(&iconed),
vec![nf_off.clone(), nf_on.clone(), "[/] ".into()]
);
assert_eq!(
task_span_state(&nf_off),
Some(' '),
"末尾スペース込みで復元"
);
assert_eq!(task_span_state(&nf_on), Some('x'));
assert_eq!(task_span_state("[ ]"), Some(' '));
assert_eq!(task_span_state("[/]"), Some('/'));
assert_eq!(task_span_state("[ab]"), None, "2文字はマーカーでない");
let mid = render_markdown("text with [ ] brackets\n", 40, BG, "TwoDark", false);
assert!(mid
.iter()
.flat_map(|l| l.spans.iter())
.all(|s| !is_task_span(s)));
}
#[test]
fn task_source_locs_skip_fences_html_and_tables() {
let src = "\
- [ ] first
```
- [x] in fence
```
<details>
- [x] in html block
</details>
| a | b |
|---|---|
| - [ ] cell | x |
- [X] nested
- [/] custom
本文 [ ] は対象外
";
let locs = task_source_locs(src, &[' ', '/', 'x']);
let got: Vec<(usize, char)> = locs.iter().map(|l| (l.line, l.state)).collect();
assert_eq!(
got,
vec![(0, ' '), (12, 'X'), (13, '/')],
"実タスクのみ: {got:?}"
);
let lines: Vec<&str> = src.lines().collect();
for l in &locs {
assert!(
lines[l.line][l.state_off..].starts_with(l.state),
"offset mismatch at line {}",
l.line
);
}
}
#[test]
fn cjk_table_is_rectangular_and_aligned() {
let md = "| 種別 | ライブラリ | 依存 |\n|------|------------|------|\n\
| md | tui-markdown | ratatui-core |\n| 図 | mermaid-text | unicode-width |\n";
let lines = render_markdown(md, 80, BG, "TwoDark", false);
assert!(
lines.len() >= 6,
"表が行に展開されていない: {}",
lines.len()
);
let w0 = line_disp_width(&lines[0]);
assert!(w0 > 0);
for (i, l) in lines.iter().enumerate() {
assert_eq!(line_disp_width(l), w0, "{i}行目の表示幅が不揃い(右枠ズレ)");
}
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|sp| sp.content.as_ref()))
.collect();
assert!(joined.contains('┌') && joined.contains('┼') && joined.contains('┘'));
}
#[test]
fn wide_table_wraps_within_terminal_width() {
let md = "| 名前 | 説明 |\n|---|---|\n\
| konoma | 全画面プレビュー特化のターミナルファイルブラウザです長い説明 |\n";
let lines = render_markdown(md, 30, BG, "TwoDark", false);
for (i, l) in lines.iter().enumerate() {
assert!(line_disp_width(l) <= 30, "{i}行目が幅30を超過");
}
let w0 = line_disp_width(&lines[0]);
assert!(lines.iter().all(|l| line_disp_width(l) == w0), "矩形でない");
}
#[test]
fn splits_mermaid_fence_out_of_markdown() {
let src = "# Title\n\nbefore\n\n```mermaid\ngraph TD\n A --> B\n```\n\nafter\n";
let segs = split_segments(src);
assert_eq!(segs.len(), 3, "got {segs:?}");
assert!(matches!(&segs[0], Segment::Md(s) if s.contains("Title")));
assert!(matches!(&segs[1], Segment::Mermaid(s) if s.contains("graph TD")));
assert!(matches!(&segs[2], Segment::Md(s) if s.contains("after")));
assert!(matches!(&segs[1], Segment::Mermaid(s) if !s.contains("```")));
}
#[test]
fn normal_code_fence_is_kept_in_markdown() {
let src = "text\n\n```rust\nlet x = 1;\n```\n";
let segs = split_segments(src);
assert_eq!(segs.len(), 1, "got {segs:?}");
assert!(matches!(&segs[0], Segment::Md(s) if s.contains("let x = 1;")));
}
#[test]
fn mermaid_inside_normal_fence_is_not_intercepted() {
let src = "~~~\n```mermaid\nnot a diagram\n```\n~~~\n";
let segs = split_segments(src);
assert!(
segs.iter().all(|s| matches!(s, Segment::Md(_))),
"got {segs:?}"
);
}
#[test]
fn renders_plain_markdown_to_lines() {
let lines = render_markdown("# Hello\n\nworld\n", 80, BG, "TwoDark", false);
assert!(!lines.is_empty());
}
#[test]
fn invalid_mermaid_falls_back_to_raw() {
let lines = render_mermaid_file("this is definitely not mermaid syntax", 80);
assert!(!lines.is_empty());
}
#[test]
fn cjk_sequence_diagram_renders_not_fallback() {
let src = "sequenceDiagram\n U->>K: ツリーで .mmd を選ぶ\n K-->>U: 全画面プレビュー";
let lines = render_mermaid_file(src, 70);
assert!(!lines.is_empty(), "CJK 入力でも行を返すこと");
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(
!joined.contains("cannot render mermaid"),
"fallback に落ちている (patch 不在の疑い): {joined}"
);
assert!(
joined
.chars()
.any(|c| ('\u{2500}'..='\u{257F}').contains(&c)),
"CJK 図に罫線が無い (panic→fallback の疑い): {joined}"
);
}
#[test]
fn ascii_sequence_diagram_renders_box_drawing() {
let src = "sequenceDiagram\n participant U as User\n participant K as konoma\n U->>K: open\n K-->>U: preview";
let lines = render_mermaid_file(src, 70);
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(
!joined.contains("cannot render mermaid"),
"fallback に落ちている: {joined}"
);
assert!(
joined
.chars()
.any(|c| ('\u{2500}'..='\u{257F}').contains(&c)),
"罫線が無い: {joined}"
);
}
#[test]
fn heading_hash_is_stripped_and_rule_added() {
let lines = render_markdown("# Title\n\nbody\n", 20, BG, "TwoDark", false);
assert_eq!(lines[0].to_string(), "Title");
assert!(
lines[1].to_string().chars().all(|c| c == '━'),
"rule 行が無い: {:?}",
lines[1].to_string()
);
}
#[test]
fn code_block_becomes_special_area() {
let lines = render_markdown(
"text\n\n```rust\nlet x = 1;\n```\n",
30,
BG,
"TwoDark",
false,
);
let coded = lines
.iter()
.find(|l| l.to_string().contains("let x = 1;"))
.expect("コード行が無い");
assert_eq!(
coded.style.bg,
Some(DEFAULT_CODE_BG),
"背景が敷かれていない"
);
assert!(coded.to_string().starts_with("▎"), "左ガターが無い");
assert!(lines.iter().all(|l| !l.to_string().contains("```")));
assert!(lines.iter().any(|l| l.to_string().contains("rust")));
}
#[test]
fn code_block_content_is_syntax_highlighted_and_indented() {
let lines = render_markdown(
"```rust\nfn f() {\n let x = 1;\n}\n```\n",
40,
BG,
"TwoDark",
false,
);
let colored = lines
.iter()
.flat_map(|l| l.spans.iter())
.any(|s| matches!(s.style.fg, Some(Color::Rgb(_, _, _))));
assert!(colored, "md コードがハイライトされていない");
let indented = lines
.iter()
.find(|l| l.to_string().contains("let x = 1;"))
.expect("コード行");
assert!(
indented.to_string().contains(" let x = 1;"),
"インデントが失われた: {:?}",
indented.to_string()
);
}
#[test]
fn code_header_gutter_is_sentinel_and_body_gutter_is_not() {
let lines = render_markdown("```rust\nlet x = 1;\n```\n", 28, BG, "TwoDark", false);
let header = lines
.iter()
.find(|l| l.to_string().contains("rust"))
.expect("言語ヘッダが無い");
assert!(
header.spans.iter().any(is_code_header_span),
"ヘッダに番兵ガターが無い"
);
let body = lines
.iter()
.find(|l| l.to_string().contains("let x = 1;"))
.expect("コード本文行が無い");
assert!(
!body.spans.iter().any(is_code_header_span),
"本文ガターを誤って番兵判定"
);
}
#[test]
fn code_block_source_locs_extracts_and_skips_mermaid() {
let src = "\
intro
```rust
fn a() {}
```
```mermaid
graph TD
A-->B
```
~~~text
plain body
~~~
";
let blocks = code_block_source_locs(src);
assert_eq!(
blocks,
vec!["fn a() {}".to_string(), "plain body".to_string()],
"``` と ~~~ を拾い mermaid は除外・生本文を保つ"
);
}
#[test]
fn code_header_language_is_a_right_aligned_badge() {
let lines = render_markdown("```rust\nlet x = 1;\n```\n", 28, BG, "TwoDark", false);
let header = lines
.iter()
.find(|l| l.to_string().contains("rust"))
.expect("言語ヘッダが無い");
assert!(
header.to_string().trim_end().ends_with("rust"),
"右寄せでない: {:?}",
header.to_string()
);
let badge = header
.spans
.iter()
.find(|s| s.content.contains("rust"))
.expect("バッジ span");
assert_eq!(
badge.style.bg,
Some(crate::config::lighten(DEFAULT_CODE_BG)),
"バッジ背景が明るくない"
);
assert_ne!(badge.style.bg, Some(DEFAULT_CODE_BG), "本文背景と同色");
}
#[test]
fn code_header_align_left_and_right() {
let right = render_markdown("```rust\nx\n```\n", 28, BG, "TwoDark", false);
let rh = right
.iter()
.find(|l| l.to_string().contains("rust"))
.unwrap();
let rs = rh.to_string();
assert!(rs.trim_end().ends_with("rust"), "右寄せでない: {rs:?}");
let right_pos = rs.find("rust").unwrap();
let left = render_markdown(
"```rust\nx\n```\n",
28,
CodeStyle {
label_right: false,
..BG
},
"TwoDark",
false,
);
let ls = left
.iter()
.find(|l| l.to_string().contains("rust"))
.unwrap()
.to_string();
let left_pos = ls.find("rust").unwrap();
assert!(
left_pos < right_pos,
"左寄せが右寄せより前に来ていない: left={left_pos} right={right_pos}"
);
}
#[test]
fn code_label_bg_is_configurable() {
let style = CodeStyle {
label_bg: Some(Color::Rgb(200, 50, 50)),
..BG
};
let lines = render_markdown("```rust\nx\n```\n", 28, style, "TwoDark", false);
let badge = lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content.contains("rust"))
.expect("バッジ span");
assert_eq!(badge.style.bg, Some(Color::Rgb(200, 50, 50)));
}
#[test]
fn code_header_badge_has_no_bg_when_code_bg_none() {
let lines = render_markdown("```rust\nx\n```\n", 28, NO_CODE, "TwoDark", false);
let badge = lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content.contains("rust"))
.expect("バッジ span");
assert_eq!(badge.style.bg, None);
}
#[test]
fn code_bg_color_is_configurable() {
let green = Color::Rgb(10, 80, 20);
let md = "本文 `inline` 続き\n\n```rust\nlet x = 1;\n```\n";
let style = CodeStyle {
bg: Some(green),
..BG
};
let lines = render_markdown(md, 40, style, "TwoDark", false);
let inline_bg = lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content.as_ref() == "inline")
.and_then(|s| s.style.bg);
assert_eq!(inline_bg, Some(green), "inline code に設定色が乗っていない");
let coded = lines
.iter()
.find(|l| l.to_string().contains("let x = 1;"))
.expect("コード行が無い");
assert_eq!(
coded.style.bg,
Some(green),
"コードブロックに設定色が乗っていない"
);
}
#[test]
fn code_bg_none_removes_all_backgrounds() {
let md = "本文 `inline` 続き\n\n```rust\nlet x = 1;\n```\n";
let lines = render_markdown(md, 40, NO_CODE, "TwoDark", false);
let inline_bg = lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content.as_ref() == "inline")
.and_then(|s| s.style.bg);
assert_eq!(inline_bg, None, "inline code の背景が消えていない");
let coded = lines
.iter()
.find(|l| l.to_string().contains("let x = 1;"))
.expect("コード行が無い");
assert_eq!(coded.style.bg, None, "コードブロックの背景が消えていない");
assert!(coded.to_string().starts_with("▎"), "左ガターは残すべき");
}
#[test]
fn cjk_in_markdown_with_mermaid_fence_does_not_panic() {
let src =
"# 図\n\n```mermaid\nsequenceDiagram\n 甲->>乙: こんにちは\n 乙-->>甲: どうも\n```\n";
let lines = render_markdown(src, 70, BG, "TwoDark", false);
assert!(!lines.is_empty());
}
#[test]
fn konoma_stylesheet_arms_return_expected_styles() {
let s = KonomaStyles {
code_bg: Some(Color::Rgb(1, 2, 3)),
};
let bq = s.blockquote();
assert_eq!(bq.fg, Some(Color::Green));
assert!(bq.add_modifier.contains(Modifier::ITALIC));
assert_eq!(s.metadata_block().fg, Some(Color::LightYellow));
assert!(s.heading_meta().add_modifier.contains(Modifier::DIM));
assert_eq!(s.heading(1).fg, Some(HEAD_FG));
assert!(s.heading(1).add_modifier.contains(Modifier::BOLD));
assert!(s.heading(3).add_modifier.contains(Modifier::ITALIC));
assert!(s.heading(6).add_modifier.contains(Modifier::DIM));
assert_eq!(s.code().bg, Some(Color::Rgb(1, 2, 3)));
let no_bg = KonomaStyles { code_bg: None };
assert_eq!(no_bg.code().bg, None);
assert!(s.link().add_modifier.contains(Modifier::UNDERLINED));
}
#[test]
fn render_markdown_with_mermaid_fence_renders_box_drawing() {
let md = "# Title\n\n```mermaid\nsequenceDiagram\n A->>B: hi\n B-->>A: yo\n```\n";
let lines = render_markdown(md, 70, BG, "TwoDark", false);
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(
!joined.contains("cannot render mermaid"),
"fallback に落ちた: {joined}"
);
assert!(
joined
.chars()
.any(|c| ('\u{2500}'..='\u{257F}').contains(&c)),
"罫線が無い: {joined}"
);
}
#[test]
fn extract_block_image_markdown_and_html() {
assert_eq!(
extract_block_image(""),
Some(("alt text".into(), "pic.png".into()))
);
assert_eq!(
extract_block_image("[](https://x)"),
Some(("a".into(), "i.png".into()))
);
assert_eq!(
extract_block_image(r#""#),
Some(("a".into(), "p.png".into()))
);
assert_eq!(
extract_block_image(r#"<img src="x.png" alt="y">"#),
Some(("y".into(), "x.png".into()))
);
assert_eq!(
extract_block_image(r#"<p align="center"><img src="hero.png" width="860"></p>"#),
Some((String::new(), "hero.png".into()))
);
assert_eq!(extract_block_image("see  here"), None);
assert_eq!(extract_block_image("just text"), None);
assert_eq!(
extract_block_image(r#"<p>text <img src="a.png"> more</p>"#),
None
);
}
#[test]
fn images_in_code_fences_are_not_extracted() {
let src = "before\n\n```\n\n```\n\nafter\n";
let slot_of = |_: &str| ImageSlot::Inline { cols: 10, rows: 4 };
let (_lines, imgs) = render_markdown_with_images(
src,
40,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
&slot_of,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
assert!(imgs.is_empty(), "fence 内の画像を誤検出: {imgs:?}");
}
#[test]
fn block_image_reserves_rows_and_records_placement() {
let src = "# Title\n\n\n\nbody\n";
let slot_of = |_: &str| ImageSlot::Inline { cols: 20, rows: 5 };
let (lines, imgs) = render_markdown_with_images(
src,
40,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
&slot_of,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
assert_eq!(imgs.len(), 1);
let p = &imgs[0];
assert_eq!((p.cols, p.rows), (20, 5));
assert_eq!(p.url, "hero.png");
assert_eq!(p.alt, "hero");
assert!(p.line < lines.len());
assert!(
lines[p.line].to_string().contains("hero"),
"placeholder label 無し"
);
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(joined.contains("body"), "画像後の本文が消えた");
}
#[test]
fn image_without_backend_degrades_to_text() {
let src = "\n";
let slot_of = |_: &str| ImageSlot::Unavailable; let (lines, imgs) = render_markdown_with_images(
src,
40,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
&slot_of,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
assert!(imgs.is_empty());
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(joined.contains("alt"), "alt テキストが無い: {joined}");
assert!(joined.contains("missing.png"));
}
#[test]
fn remote_image_shows_loading_line() {
let src = "\n";
let slot_of = |_: &str| ImageSlot::Loading;
let (lines, imgs) = render_markdown_with_images(
src,
60,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
&slot_of,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
assert!(imgs.is_empty(), "loading 中は placement を出さない");
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(joined.contains("loading"), "loading 表示が無い: {joined}");
}
#[test]
fn collect_remote_image_urls_finds_http_only() {
let src = "\


<p><img src=\"http://example.com/html.png\"></p>
```

```
";
let urls = collect_remote_image_urls(src);
assert_eq!(
urls,
vec![
"https://example.com/remote.png".to_string(),
"http://example.com/html.png".to_string(),
],
"remote のみ・fence 内は除外・順序保持: {urls:?}"
);
}
#[test]
fn code_block_cache_hits_are_identical_and_bounded() {
let body: Vec<String> = vec!["fn main() {}".into(), "let x = 1;".into()];
let a = highlight_body(&body, "rust", 60, None, "TwoDark", 4, true);
let b = highlight_body(&body, "rust", 60, None, "TwoDark", 4, true);
assert_eq!(a.len(), b.len());
for (la, lb) in a.iter().zip(&b) {
assert_eq!(la.spans.len(), lb.spans.len());
for (sa, sb) in la.spans.iter().zip(&lb.spans) {
assert_eq!(
(sa.content.as_ref(), sa.style),
(sb.content.as_ref(), sb.style)
);
}
}
for i in 0..(CODE_BLOCK_CACHE_CAP + 20) {
let one = vec![format!("let v{i} = {i};")];
let _ = highlight_body(&one, "rust", 60, None, "TwoDark", 4, true);
}
assert!(code_block_cache_len() <= CODE_BLOCK_CACHE_CAP);
}
#[test]
fn mermaid_to_svg_output_rasterizes_with_konoma_resvg() {
let svg = mermaid_to_svg(
"graph TD\n A[ツリー] -->|Enter| B{種別解決}\n B --> C[プレビュー]",
"dark",
)
.expect("mermaid renders to SVG");
assert!(svg.contains("<svg"), "SVG らしい出力");
let img = crate::preview::svg::rasterize_bytes(
svg.as_bytes(),
std::path::Path::new("m.svg"),
400,
)
.expect("konoma の resvg でラスタライズできる");
assert!(img.width() > 0 && img.height() > 0);
}
#[test]
fn mermaid_dark_theme_uses_modern_font_metrics() {
let svg = mermaid_to_svg("graph LR\nA-->B", "dark").unwrap();
assert!(svg.contains("Inter"), "modern のフォントファミリで計測");
assert!(!svg.contains("trebuchet"), "dark 固有フォントは使わない");
}
#[test]
fn mermaid_to_svg_fails_safely_on_garbage() {
assert!(mermaid_to_svg("definitely not a diagram !!!", "dark").is_none());
}
#[test]
fn catch_silent_returns_none_on_panic_and_some_on_success() {
assert_eq!(catch_silent(|| 42), Some(42));
let paniced: Option<i32> = catch_silent(|| panic!("worker blew up"));
assert_eq!(paniced, None);
PANIC_SILENCED.with(|c| assert!(!c.get(), "panic 経路でも抑制フラグが残らない"));
assert_eq!(catch_silent(|| "ok"), Some("ok"));
}
#[test]
fn concurrent_mermaid_renders_keep_panic_hook_sane() {
let hs: Vec<_> = (0..8)
.map(|i| {
std::thread::spawn(move || {
let code = if i % 2 == 0 {
"garbage ]][[ not a diagram".to_string()
} else {
format!("graph LR\n A{i} --> B{i}")
};
(mermaid_to_svg(&code, "dark").is_some(), i % 2 == 1)
})
})
.collect();
for h in hs {
let (got, expect) = h.join().unwrap();
assert_eq!(got, expect, "並行レンダでも成否は入力どおり");
}
PANIC_SILENCED.with(|c| assert!(!c.get(), "抑制フラグが残留しない"));
}
#[test]
fn collect_mermaid_fences_top_level_only() {
let src = "# t\n```mermaid\ngraph LR\nA-->B\n```\n\n````md\n```mermaid\ninner\n```\n````\n";
let fences = collect_mermaid_fences(src);
assert_eq!(fences.len(), 1, "外側フェンス内の mermaid は抽出しない");
assert_eq!(fences[0], "graph LR\nA-->B\n");
let unterminated = "```mermaid\ngraph LR\nA-->B\n";
assert!(collect_mermaid_fences(unterminated).is_empty());
}
#[test]
fn mermaid_slots_image_loading_text() {
let src = "before\n\n```mermaid\ngraph LR\nA-->B\n```\n\nafter\n";
let slot_img = |_: &str| MermaidSlot::Image { cols: 20, rows: 5 };
let (lines, imgs) = render_markdown_with_images(
src,
60,
CodeStyle::default(),
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_| ImageSlot::Unavailable,
&slot_img,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
assert_eq!(imgs.len(), 1, "フェンスが placement になる");
assert!(is_mermaid_fence_url(&imgs[0].url), "合成キー URL");
let cap = &lines[imgs[0].line - 1];
assert!(
cap.spans.iter().any(is_mermaid_header_span),
"キャプション行に番兵 span"
);
let (lines, imgs) = render_markdown_with_images(
src,
60,
CodeStyle::default(),
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_| ImageSlot::Unavailable,
&|_: &str| MermaidSlot::Loading,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
assert!(imgs.is_empty());
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(joined.contains("loading"), "ローディング行: {joined}");
let (lines, imgs) = render_markdown_with_images(
src,
60,
CodeStyle::default(),
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_| ImageSlot::Unavailable,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
assert!(imgs.is_empty());
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(
joined.contains('A') && joined.contains('B'),
"テキスト図として描画: {joined}"
);
}
#[test]
fn fence_caption_is_localizable_but_sentinel_survives() {
let src = "```mermaid\ngraph LR\nA-->B\n```\n";
let slot_img = |_: &str| MermaidSlot::Image { cols: 20, rows: 5 };
let render = |caption: &str| {
render_markdown_with_images(
src,
60,
CodeStyle::default(),
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_| ImageSlot::Unavailable,
&slot_img,
caption,
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
)
};
let (en, ei) = render("Enter: full screen");
let (ja, ji) = render("Enter: 全画面");
let cap_en = en[ei[0].line - 1].to_string();
let cap_ja = ja[ji[0].line - 1].to_string();
assert!(
cap_en.contains("Enter: full screen"),
"en キャプション: {cap_en}"
);
assert!(
cap_ja.contains("Enter: 全画面"),
"ja キャプション: {cap_ja}"
);
assert_ne!(cap_en, cap_ja, "言語でキャプションが変わる");
for (lines, imgs) in [(en, ei), (ja, ji)] {
assert!(
lines[imgs[0].line - 1]
.spans
.iter()
.any(is_mermaid_header_span),
"番兵 span が残る"
);
}
}
#[test]
fn parse_alert_header_recognizes_types_and_aliases() {
assert_eq!(parse_alert_header("> [!NOTE]").unwrap().0, AlertKind::Note);
assert_eq!(
parse_alert_header("> [!warning]").unwrap().0,
AlertKind::Warning
);
let (k, title) = parse_alert_header("> [!danger] Watch out").unwrap();
assert_eq!(k, AlertKind::Caution);
assert_eq!(title, "Watch out");
assert!(parse_alert_header("> just a quote").is_none());
assert!(parse_alert_header("> [!NOPE]").is_none());
assert!(parse_alert_header("[!NOTE]").is_none()); }
#[test]
fn split_alerts_captures_body_and_surrounding_text() {
let md = "intro\n\n> [!TIP]\n> be **bold**\n> line two\n\nafter\n";
let parts = split_alerts(md);
assert_eq!(parts.len(), 3);
assert!(matches!(&parts[0], AlertPart::Text(t) if t.contains("intro")));
match &parts[1] {
AlertPart::Alert { kind, body, .. } => {
assert_eq!(*kind, AlertKind::Tip);
assert!(body.contains("be **bold**") && body.contains("line two"));
assert!(!body.contains('>'), "blockquote markers stripped from body");
}
_ => panic!("expected an alert"),
}
assert!(matches!(&parts[2], AlertPart::Text(t) if t.contains("after")));
}
#[test]
fn render_alert_makes_a_colored_callout_not_literal_marker() {
let md = "> [!WARNING]\n> careful with [docs](./x.md)\n";
let on =
render_markdown_tasks_opts(md, 60, BG, "TwoDark", false, DEFAULT_TASK_STATES, true);
let joined: String = on
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
joined.contains("Warning"),
"callout shows the label: {joined:?}"
);
assert!(
!joined.contains("[!WARNING]"),
"the raw marker is gone: {joined:?}"
);
assert!(
on[0]
.spans
.iter()
.any(|s| s.content.contains('▌') && s.style.fg == Some(Color::Yellow)),
"colored left bar on the header"
);
let has_link_label = on
.iter()
.flat_map(|l| l.spans.iter())
.any(|s| s.content.contains("docs"));
assert!(has_link_label, "alert body Markdown is rendered");
let off =
render_markdown_tasks_opts(md, 60, BG, "TwoDark", false, DEFAULT_TASK_STATES, false);
let joined_off: String = off
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
joined_off.contains("[!WARNING]"),
"alerts off keeps the raw marker: {joined_off:?}"
);
}
#[test]
fn process_inline_html_converts_common_tags() {
assert!(process_inline_html("<del>gone</del>\n").contains("~~gone~~"));
assert!(process_inline_html("<s>x</s>\n").contains("~~x~~"));
assert!(process_inline_html("<strike>y</strike>\n").contains("~~y~~"));
assert!(process_inline_html("<kbd>Ctrl</kbd>\n").contains("`Ctrl`"));
assert!(process_inline_html("H<sub>2</sub>O and x<sup>2</sup>\n").contains("H₂O and x²"));
assert!(process_inline_html("<sup>note</sup>\n").contains("note"));
assert!(process_inline_html("a<br>b\n").contains("a \nb"));
}
#[test]
fn process_inline_html_leaves_fences_untouched() {
let out = process_inline_html("```\n<kbd>x</kbd>\n```\n");
assert!(
out.contains("<kbd>x</kbd>"),
"tags inside a fence stay literal"
);
}
#[test]
fn to_superscript_single_and_multi_digit() {
assert_eq!(to_superscript(1), "¹");
assert_eq!(to_superscript(10), "¹⁰");
assert_eq!(to_superscript(12), "¹²");
}
#[test]
fn process_footnotes_superscripts_refs_and_appends_section() {
let src = "See note.[^a] And another.[^b]\n\n[^a]: first def\n[^b]: second def\n";
let out = process_footnotes(src);
assert!(out.contains("See note.¹"), "out = {out:?}");
assert!(out.contains("And another.²"));
assert!(!out.contains("[^a]:"));
assert!(out.contains("1. first def"));
assert!(out.contains("2. second def"));
assert!(
out.contains("---"),
"a rule separates the footnotes section"
);
}
#[test]
fn process_footnotes_leaves_fences_and_undefined_refs() {
let src = "text[^1] and [^nodef]\n\n```\ncode [^1] here\n```\n\n[^1]: def one\n";
let out = process_footnotes(src);
assert!(out.contains("text¹"), "defined ref superscripted");
assert!(out.contains("[^nodef]"), "undefined ref stays literal");
assert!(
out.contains("code [^1] here"),
"ref inside a fence untouched"
);
}
#[test]
fn process_footnotes_no_definitions_is_noop() {
let src = "just [^1] with no definition\n";
assert_eq!(process_footnotes(src), src);
}
#[test]
fn heading_level_hint_infers_levels_from_style() {
let md = "# H1\n\n## H2\n\n### H3\n\n#### H4\n\n##### H5\n";
let lines = render_markdown(md, 40, NO_CODE, "TwoDark", false);
let levels: Vec<u8> = lines
.iter()
.enumerate()
.filter(|(_, l)| heading_text(l).is_some())
.map(|(i, l)| heading_level_hint(l, lines.get(i + 1)))
.collect();
assert_eq!(levels, vec![1, 2, 3, 4, 4]);
}
#[test]
fn heading_inside_alert_strips_bar_for_anchor() {
let md = "> [!NOTE]\n> ## Sub Heading\n> body\n";
let lines = render_markdown_tasks_opts(
md,
60,
NO_CODE,
"TwoDark",
false,
DEFAULT_TASK_STATES,
true,
);
let ht = lines.iter().find_map(heading_text);
assert_eq!(ht.as_deref(), Some("Sub Heading"));
}
#[test]
fn strip_front_matter_extracts_leading_block_only() {
let (fm, body) =
strip_front_matter("---\ntitle: Hi\ntags: [a, b]\n---\n# Heading\n\nbody\n");
assert_eq!(fm.as_deref(), Some("title: Hi\ntags: [a, b]"));
assert!(body.starts_with("# Heading"), "body = {body:?}");
assert_eq!(
strip_front_matter("---\nk: v\n...\nrest\n").0.as_deref(),
Some("k: v")
);
assert_eq!(strip_front_matter("# not front matter\n").0, None);
assert_eq!(strip_front_matter("---\njust text, no close\n").0, None);
}
#[test]
fn render_front_matter_accents_keys_and_closes_with_rule() {
let lines = render_front_matter("title: Hi\n nested: x\nplain", 20);
assert!(lines[0].spans[0].content.starts_with("title"));
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Cyan));
assert!(lines[0].spans[0].style.add_modifier.contains(Modifier::DIM));
assert!(lines
.iter()
.any(|l| l.spans.iter().any(|s| s.content.contains('─'))));
assert!(lines.iter().all(|l| l.style.fg != Some(HEAD_FG)));
}
#[test]
fn alert_body_code_fence_is_detected_as_code_line() {
let md = "> [!NOTE]\n> ```sh\n> curl https://x.example # :tada:\n> ```\n";
let lines = render_markdown_tasks_opts(
md,
60,
NO_CODE,
"TwoDark",
false,
DEFAULT_TASK_STATES,
true,
);
let code_line = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.contains("curl")))
.expect("the fenced body line is present");
assert!(
is_code_line(code_line),
"an alert-wrapped code line is detected as code: {:?}",
code_line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
);
let fake = Line::from(vec![Span::raw("▎ see https://x.com")]);
assert!(!is_code_line(&fake), "plain ▎ text is not a code line");
}
}