use std::collections::HashMap;
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span};
use pulldown_cmark::Options as ParseOptions;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
pub(crate) mod model;
pub(crate) mod render;
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 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());
let slot_of = |_: &str, _: Option<u16>| ImageSlot::Unavailable;
let mermaid_slot = |_: &str| MermaidSlot::Image { cols: 20, rows: 5 };
let math_slot = |_: &str, _: bool| MathSlot::Raw;
render_markdown_with_images(
src,
width,
code,
theme,
icons,
tasks,
&slot_of,
&mermaid_slot,
"mermaid",
true,
&math_slot,
true,
)
.0
}
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())
}
pub(crate) fn compute_or_fallback<T>(f: impl FnOnce() -> T, fallback: impl FnOnce() -> T) -> T {
catch_silent(f).unwrap_or_else(fallback)
}
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
}
#[derive(Clone, Debug, PartialEq)]
pub struct ImagePlacement {
pub url: String,
pub alt: String,
pub line: usize,
pub col: u16,
pub cols: u16,
pub rows: u16,
pub fence_ord: Option<usize>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct SourceRun {
text: String,
code: Vec<bool>,
}
impl SourceRun {
fn parse(text: String) -> Self {
let code = splitter_code_mask(&text.lines().collect::<Vec<_>>());
Self { text, code }
}
fn new(text: String, code: Vec<bool>) -> Self {
debug_assert_eq!(
code.len(),
text.lines().count(),
"SourceRun mask/line-count drift for {text:?}"
);
Self { text, code }
}
fn text(&self) -> &str {
&self.text
}
fn code(&self) -> &[bool] {
&self.code
}
fn lines(&self) -> Vec<&str> {
self.text.lines().collect()
}
}
#[cfg(test)]
fn doc_run(src: &str) -> SourceRun {
SourceRun::parse(src.to_string())
}
enum BlockPart {
Text(SourceRun),
Image {
url: String,
},
Mermaid {
code: String,
},
}
enum MathPart {
Text(SourceRun),
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,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum BlockAlign {
#[default]
Left,
Center,
Right,
}
impl BlockAlign {
pub fn from_config(s: &str, default: BlockAlign) -> BlockAlign {
match s.trim().to_ascii_lowercase().as_str() {
"left" => BlockAlign::Left,
"center" => BlockAlign::Center,
"right" => BlockAlign::Right,
_ => default,
}
}
pub fn offset(self, width: u16, content: u16) -> u16 {
let slack = width.saturating_sub(content);
match self {
BlockAlign::Left => 0,
BlockAlign::Center => slack / 2,
BlockAlign::Right => slack,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BlockAligns {
pub table: BlockAlign,
pub image: BlockAlign,
}
impl Default for BlockAligns {
fn default() -> Self {
Self {
table: BlockAlign::Left,
image: BlockAlign::Center,
}
}
}
pub fn mermaid_diagram_col(align: BlockAlign, width: u16, cols: u16) -> u16 {
if width >= cols + 2 {
align.offset(width, cols + 2) + 1
} else {
align.offset(width, cols)
}
}
pub fn mermaid_focus_border_x(align: BlockAlign, pane: u16, bw: u16) -> u16 {
align.offset(pane, bw)
}
#[derive(Default)]
pub struct MdRenderExtras {
pub code_blocks: Vec<String>,
pub tasks: Vec<(char, usize)>,
}
#[cfg(test)]
#[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, Option<u16>) -> 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>, MdRenderExtras) {
render_markdown_with_images_aligned(
src,
width,
code,
theme,
icons,
tasks,
slot_of,
mermaid_slot,
mermaid_caption,
alerts,
math_slot,
math_on,
BlockAligns::default(),
)
}
#[allow(clippy::too_many_arguments)] pub fn render_markdown_with_images_aligned(
src: &str,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
tasks: &[char],
slot_of: &dyn Fn(&str, Option<u16>) -> ImageSlot,
mermaid_slot: &dyn Fn(&str) -> MermaidSlot,
mermaid_caption: &str,
alerts: bool,
math_slot: &dyn Fn(&str, bool) -> MathSlot,
math_on: bool,
aligns: BlockAligns,
) -> (Vec<Line<'static>>, Vec<ImagePlacement>, MdRenderExtras) {
let doc = model::Doc::parse(src);
let out = render::render_doc_aligned(
&doc,
src,
width,
code,
theme,
icons,
tasks,
slot_of,
mermaid_slot,
mermaid_caption,
alerts,
math_slot,
math_on,
aligns,
);
(
out.lines,
out.images,
MdRenderExtras {
code_blocks: out.code_blocks,
tasks: out.tasks,
},
)
}
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 inline_math_reservation_style() -> Style {
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::HIDDEN)
}
pub(crate) fn is_inline_math_reservation_span(span: &Span<'_>) -> bool {
span.style == inline_math_reservation_style()
&& !span.content.is_empty()
&& span.content.chars().all(|c| c == ' ')
}
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(col: u16, rows: u16, width: u16, caption: &str) -> Vec<Line<'static>> {
let rows = rows.max(1);
let head = format!("◇ mermaid — {caption}");
let indent = " ".repeat(col.min(width.saturating_sub(head.width() as u16)) as usize);
let mut lines = Vec::with_capacity(rows as usize + 2);
lines.push(Line::from(vec![
Span::raw(indent),
Span::styled(head, 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> {
extraction_targets(src).remote_urls
}
fn collect_remote_image_urls_legacy(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
}
struct ExtractionTargets {
remote_urls: Vec<String>,
mermaid_fences: Vec<String>,
math_exprs: Vec<(String, bool)>,
}
fn extraction_targets(src: &str) -> ExtractionTargets {
let doc = model::Doc::parse(src);
let remote_urls: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());
let mermaid_fences: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());
let math_exprs: std::cell::RefCell<Vec<(String, bool)>> = std::cell::RefCell::new(Vec::new());
let slot_of = |url: &str, _: Option<u16>| {
remote_urls.borrow_mut().push(url.to_string());
ImageSlot::Inline { cols: 1, rows: 1 }
};
let mermaid_slot = |code: &str| {
if !code.is_empty() {
mermaid_fences.borrow_mut().push(code.to_string());
}
MermaidSlot::Image { cols: 1, rows: 1 }
};
let math_slot = |latex: &str, display: bool| {
math_exprs.borrow_mut().push((latex.to_string(), display));
MathSlot::Image { cols: 1, rows: 1 }
};
let out = render::render_doc(
&doc,
src,
100,
CodeStyle::default(),
"TwoDark",
false,
DEFAULT_TASK_STATES,
&slot_of,
&mermaid_slot,
"mermaid",
true, &math_slot,
true,
);
if out.unsupported.is_empty() {
ExtractionTargets {
remote_urls: remote_urls
.into_inner()
.into_iter()
.filter(|u| is_remote_image_url(u))
.collect(),
mermaid_fences: mermaid_fences.into_inner(),
math_exprs: math_exprs.into_inner(),
}
} else {
ExtractionTargets {
remote_urls: collect_remote_image_urls_legacy(src),
mermaid_fences: collect_mermaid_fences_legacy(src),
math_exprs: collect_math_exprs_legacy(src),
}
}
}
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 doc = splitter_code_mask(&src.lines().collect::<Vec<_>>());
split_block_parts_masked(src, &doc, mermaid_fences)
}
#[cfg(test)]
fn split_block_parts_run(run: &SourceRun, mermaid_fences: bool) -> Vec<BlockPart> {
split_block_parts_masked(run.text(), run.code(), mermaid_fences)
}
fn split_block_parts_masked(src: &str, doc: &[bool], mermaid_fences: bool) -> Vec<BlockPart> {
debug_assert_eq!(
doc.len(),
src.lines().count(),
"split_block_parts mask/line-count drift"
);
let mut parts = Vec::new();
let mut text = String::new();
let mut mask: Vec<bool> = Vec::new();
let mut open: Option<(u8, usize)> = None;
let mut mermaid: Option<String> = None;
for (i, line) in src.split_inclusive('\n').enumerate() {
let bare = line.strip_suffix('\n').unwrap_or(line);
let in_code = doc.get(i).copied().unwrap_or(false);
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(SourceRun::new(
std::mem::take(&mut text),
std::mem::take(&mut mask),
)));
}
mermaid = Some(String::new());
} else {
text.push_str(line);
mask.push(in_code);
}
} else if let Some((_alt, url)) =
(!in_code).then(|| extract_block_image(bare)).flatten()
{
if !text.is_empty() {
parts.push(BlockPart::Text(SourceRun::new(
std::mem::take(&mut text),
std::mem::take(&mut mask),
)));
}
parts.push(BlockPart::Image { url });
} else {
text.push_str(line);
mask.push(in_code);
}
}
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);
mask.push(in_code);
}
}
if closing {
open = None;
}
}
}
}
let reverted_mermaid = mermaid.is_some();
if let Some(code) = mermaid {
text.push_str("```mermaid\n");
text.push_str(&code);
}
if !text.is_empty() {
parts.push(BlockPart::Text(if reverted_mermaid {
SourceRun::parse(text)
} else {
SourceRun::new(text, mask)
}));
}
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(items: &[(u16, u16, &str)], rows: u16) -> Vec<Line<'static>> {
let rows = rows.max(1);
let mut row = String::new();
let mut cur: usize = 0;
for &(col, cols, alt) in items {
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 col = col as usize;
if col > cur {
row.push_str(&" ".repeat(col - cur));
cur = col;
}
cur += label.width();
row.push_str(&label);
}
let mut lines = Vec::with_capacity(rows as usize);
lines.push(Line::from(row).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_headings_and_extras(
lines: Vec<Line<'static>>,
width: u16,
icons: bool,
tasks: &[char],
) -> Vec<Line<'static>> {
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, usize)> {
let bytes = t.as_bytes();
if !matches!(bytes.first(), Some(b'-' | b'*' | b'+')) {
return None;
}
let spaces = bytes[1..].iter().take_while(|&&c| c == b' ').count();
if spaces == 0 || spaces > 4 {
return None;
}
let rest = t[1 + spaces..].strip_prefix('[')?;
let c = rest.chars().next()?;
let tail = &rest[c.len_utf8()..];
let closed = tail.strip_prefix(']').is_some_and(|after| {
after.is_empty() || after.starts_with([' ', '\t'])
});
if !closed {
return None;
}
is_task_state(c, tasks).then_some((c, 1 + spaces + 1))
}
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))
}
fn leading_ws_width(line: &str) -> usize {
let mut col = 0usize;
for ch in line.chars() {
match ch {
' ' => col += 1,
'\t' => col = (col / 4 + 1) * 4,
_ => break,
}
}
col
}
#[cfg(test)]
fn strip_ws_columns(line: &str, cols: usize) -> &str {
let mut col = 0usize;
let mut idx = 0usize;
for ch in line.chars() {
if col >= cols {
break;
}
match ch {
' ' => {
col += 1;
idx += ch.len_utf8();
}
'\t' => {
col = (col / 4 + 1) * 4;
idx += ch.len_utf8();
}
_ => break,
}
}
&line[idx..]
}
#[cfg(test)]
fn looks_like_list_marker(t: &str) -> bool {
let bytes = t.as_bytes();
if bytes.is_empty() {
return false;
}
let marker_end = if matches!(bytes[0], b'-' | b'*' | b'+') {
1
} else {
let mut j = 0;
while j < bytes.len() && j < 9 && bytes[j].is_ascii_digit() {
j += 1;
}
if j > 0 && j < bytes.len() && matches!(bytes[j], b'.' | b')') {
j + 1
} else {
return false;
}
};
marker_end == bytes.len() || bytes[marker_end] == b' '
}
#[cfg(test)]
fn is_atx_heading_line(line: &str) -> bool {
let ws = leading_ws_width(line);
if ws > 3 {
return false;
}
let rest = strip_ws_columns(line, ws);
let hashes = rest.chars().take_while(|&c| c == '#').count();
if hashes == 0 || hashes > 6 {
return false;
}
matches!(rest.as_bytes().get(hashes), None | Some(b' ') | Some(b'\t'))
}
#[cfg(test)]
fn is_thematic_break_line(line: &str) -> bool {
let ws = leading_ws_width(line);
if ws > 3 {
return false;
}
let rest = strip_ws_columns(line, ws);
for marker in ['-', '_', '*'] {
let count = rest.chars().filter(|&c| c == marker).count();
if count >= 3 && rest.chars().all(|c| c == marker || c == ' ' || c == '\t') {
return true;
}
}
false
}
#[cfg(test)]
fn is_setext_underline_line(line: &str) -> bool {
let ws = leading_ws_width(line);
if ws > 3 {
return false;
}
let rest = strip_ws_columns(line, ws).trim_end();
!rest.is_empty() && (rest.chars().all(|c| c == '=') || rest.chars().all(|c| c == '-'))
}
#[cfg(test)]
struct ListGuard {
in_list: bool,
}
#[cfg(test)]
impl ListGuard {
fn new() -> Self {
Self { in_list: false }
}
fn observe(&mut self, ws: usize, rest: &str) -> bool {
if ws == 0 {
self.in_list = looks_like_list_marker(rest);
}
self.in_list
}
fn observe_special(&mut self, ws: usize) {
if ws == 0 {
self.in_list = false;
}
}
}
#[cfg(test)]
fn skip_indented_code_block(lines: &[&str], i: &mut usize) {
while let Some(line) = lines.get(*i) {
if line.trim().is_empty() {
let mut k = *i;
while k < lines.len() && lines[k].trim().is_empty() {
k += 1;
}
if k < lines.len() && leading_ws_width(lines[k]) >= 4 {
*i = k; continue;
}
break; }
if leading_ws_width(line) >= 4 {
*i += 1;
continue;
}
break;
}
}
fn markdown_parse_options() -> ParseOptions {
let mut o = ParseOptions::empty();
o.insert(ParseOptions::ENABLE_STRIKETHROUGH);
o.insert(ParseOptions::ENABLE_TASKLISTS);
o.insert(ParseOptions::ENABLE_HEADING_ATTRIBUTES);
o.insert(ParseOptions::ENABLE_YAML_STYLE_METADATA_BLOCKS);
o.insert(ParseOptions::ENABLE_SUPERSCRIPT);
o.insert(ParseOptions::ENABLE_SUBSCRIPT);
o
}
#[cfg(test)]
fn parser_code_blocks(text: &str, out: &mut Vec<String>) {
use pulldown_cmark::{Event, Parser, Tag, TagEnd};
let mut quote_depth = 0usize;
let mut body: Option<String> = None;
let mut drawn = false;
for ev in Parser::new_ext(text, markdown_parse_options()) {
match ev {
Event::Start(Tag::BlockQuote(_)) => quote_depth += 1,
Event::End(TagEnd::BlockQuote(_)) => quote_depth = quote_depth.saturating_sub(1),
Event::Start(Tag::CodeBlock(_)) => {
drawn = quote_depth == 0;
body = Some(String::new());
}
Event::End(TagEnd::CodeBlock) => {
if let Some(b) = body.take() {
if drawn {
out.push(b.strip_suffix('\n').unwrap_or(&b).to_string());
}
}
}
Event::Text(t) => {
if let Some(b) = body.as_mut() {
b.push_str(&t);
}
}
_ => {}
}
}
}
#[cfg(test)]
fn scan_code_run(run: &mut Vec<(&str, bool)>, in_alert_body: bool, out: &mut Vec<String>) {
if run.is_empty() {
return;
}
let mut text = run.iter().map(|(l, _)| *l).collect::<Vec<_>>().join("\n");
text.push('\n');
let code: Vec<bool> = run.iter().map(|(_, c)| *c).collect();
run.clear();
let text = SourceRun::new(text, code);
if in_alert_body {
scan_text_part(&text, out);
return;
}
for part in split_block_parts_run(&text, true) {
if let BlockPart::Text(t) = part {
scan_text_part(&t, out);
}
}
}
#[cfg(test)]
fn scan_text_part(text: &SourceRun, out: &mut Vec<String>) {
for part in split_tables(text) {
let MdPart::Text(t) = part else {
continue; };
for hp in split_html_blocks(&t) {
match hp {
HtmlPart::Text(t2) => parser_code_blocks(t2.text(), out),
HtmlPart::Html(_) => {} }
}
}
}
#[cfg(test)]
pub(crate) fn code_block_source_locs(src: &str, details_open: &[bool]) -> Vec<String> {
code_block_source_locs_inner(src, details_open, false)
}
#[cfg(test)]
fn code_block_source_locs_inner(
src: &str,
details_open: &[bool],
in_alert_body: bool,
) -> Vec<String> {
let lines: Vec<&str> = src.lines().collect();
let mut out = Vec::new();
let mut details_idx = 0usize;
let mut i = 0;
let mut run: Vec<(&str, bool)> = Vec::new();
let in_code = splitter_code_mask(&lines);
while i < lines.len() {
if in_code[i] {
run.push((lines[i], true));
i += 1;
continue;
}
if parse_alert_header(lines[i]).is_some() {
scan_code_run(&mut run, in_alert_body, &mut out);
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;
}
out.extend(code_block_source_locs_inner(&body, &[], true));
continue;
}
if let Some(open_attr) = details_open_tag(lines[i]) {
scan_code_run(&mut run, in_alert_body, &mut out);
let open = details_open.get(details_idx).copied().unwrap_or(open_attr);
details_idx += 1;
i += 1;
let mut body = Vec::new();
while i < lines.len() && !is_details_close(lines[i]) {
body.push(lines[i]);
i += 1;
}
if i < lines.len() {
i += 1; }
if open {
out.extend(code_block_source_locs_inner(
&body.join("\n"),
&[],
in_alert_body,
));
}
continue;
}
run.push((lines[i], in_code[i]));
i += 1;
}
scan_code_run(&mut run, in_alert_body, &mut out);
out
}
pub(crate) struct TaskLoc {
pub line: usize,
pub state_off: usize,
#[allow(dead_code)]
pub state: char,
}
pub(crate) fn byte_to_line_offset(src: &str, byte: usize) -> Option<(usize, usize)> {
let mut start = 0usize;
for (i, line) in src.split('\n').enumerate() {
let end = start + line.len();
if byte <= end {
return Some((i, byte - start));
}
start = end + 1;
}
None
}
#[cfg(test)]
fn scan_task_lines(logical: &[(usize, &str, usize)], tasks: &[char], out: &mut Vec<TaskLoc>) {
let mut list = ListGuard::new();
let mut prev_not_paragraph = true;
let mut idx = 0usize;
while idx < logical.len() {
let (orig_line, text, prefix) = logical[idx];
let ws = leading_ws_width(text);
let rest = strip_ws_columns(text, ws);
let indent = text.len() - rest.len();
if rest.trim().is_empty() {
prev_not_paragraph = true;
idx += 1;
continue;
}
if parse_alert_header(text).is_some() {
list.observe_special(ws);
prev_not_paragraph = true;
idx += 1;
let mut nested: Vec<(usize, String, usize)> = Vec::new();
while idx < logical.len() && is_blockquote_line(logical[idx].1) {
let (oln, txt, pfx) = logical[idx];
let stripped = strip_blockquote(txt);
let extra = txt.len() - stripped.len();
nested.push((oln, stripped, pfx + extra));
idx += 1;
}
let refs: Vec<(usize, &str, usize)> = nested
.iter()
.map(|(o, s, p)| (*o, s.as_str(), *p))
.collect();
scan_task_lines(&refs, tasks, out);
continue;
}
if let Some(open_attr) = details_open_tag(text) {
list.observe_special(ws);
prev_not_paragraph = true;
idx += 1;
let mut nested: Vec<(usize, &str, usize)> = Vec::new();
while idx < logical.len() && !is_details_close(logical[idx].1) {
nested.push(logical[idx]);
idx += 1;
}
if idx < logical.len() {
idx += 1; }
if open_attr {
scan_task_lines(&nested, tasks, out);
}
continue;
}
if is_atx_heading_line(text) || is_thematic_break_line(text) {
list.observe_special(ws);
prev_not_paragraph = true;
idx += 1;
continue;
}
if !prev_not_paragraph && is_setext_underline_line(text) {
list.observe_special(ws);
prev_not_paragraph = true;
idx += 1;
continue;
}
let in_list = list.observe(ws, rest);
if !in_list && ws >= 4 && prev_not_paragraph {
while let Some(&(_, t2, _)) = logical.get(idx) {
if t2.trim().is_empty() {
let mut k = idx;
while k < logical.len() && logical[k].1.trim().is_empty() {
k += 1;
}
if k < logical.len() && leading_ws_width(logical[k].1) >= 4 {
idx = k;
continue;
}
break;
}
if leading_ws_width(t2) >= 4 {
idx += 1;
continue;
}
break;
}
prev_not_paragraph = true;
continue;
}
if let Some((state, off)) = task_prefix_state(rest, tasks) {
out.push(TaskLoc {
line: orig_line,
state_off: prefix + indent + off,
state,
});
}
prev_not_paragraph = false;
idx += 1;
}
}
#[cfg(test)]
pub(crate) fn task_source_locs(src: &str, tasks: &[char], details_open: &[bool]) -> Vec<TaskLoc> {
let lines: Vec<&str> = src.lines().collect();
let mut out = Vec::new();
let in_code = splitter_code_mask(&lines);
let mut details_idx = 0usize;
let mut i = 0;
let mut list = ListGuard::new();
let mut prev_not_paragraph = true;
while i < lines.len() {
let line = lines[i];
let t = line.trim_start();
let ws = leading_ws_width(line);
if in_code[i] {
if i == 0 || !in_code[i - 1] {
list.observe_special(ws);
}
prev_not_paragraph = true;
i += 1;
continue;
}
if parse_alert_header(line).is_some() {
list.observe_special(ws);
prev_not_paragraph = true;
i += 1;
let mut logical: Vec<(usize, String, usize)> = Vec::new();
while i < lines.len() && is_blockquote_line(lines[i]) {
let raw = lines[i];
let stripped = strip_blockquote(raw);
let prefix = raw.len() - stripped.len();
logical.push((i, stripped, prefix));
i += 1;
}
let refs: Vec<(usize, &str, usize)> = logical
.iter()
.map(|(idx, s, p)| (*idx, s.as_str(), *p))
.collect();
scan_task_lines(&refs, tasks, &mut out);
continue;
}
if let Some(open_attr) = details_open_tag(line) {
list.observe_special(ws);
prev_not_paragraph = true;
let open = details_open.get(details_idx).copied().unwrap_or(open_attr);
details_idx += 1;
i += 1;
let mut body = Vec::new();
while i < lines.len() && !is_details_close(lines[i]) {
body.push(i);
i += 1;
}
if i < lines.len() {
i += 1; }
if open {
let logical: Vec<(usize, &str, usize)> =
body.iter().map(|&bi| (bi, lines[bi], 0usize)).collect();
scan_task_lines(&logical, tasks, &mut out);
}
continue;
}
if is_html_block_start(line) {
list.observe_special(ws);
prev_not_paragraph = true;
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]) {
list.observe_special(ws);
prev_not_paragraph = true;
i += 2;
while i < lines.len() && looks_like_table_row(lines[i]) {
i += 1;
}
continue;
}
if is_atx_heading_line(line) || is_thematic_break_line(line) {
list.observe_special(ws);
prev_not_paragraph = true;
i += 1;
continue;
}
if !prev_not_paragraph && is_setext_underline_line(line) {
list.observe_special(ws);
prev_not_paragraph = true;
i += 1;
continue;
}
if t.trim().is_empty() {
prev_not_paragraph = true;
i += 1;
continue;
}
let in_list = list.observe(ws, t);
if !in_list && ws >= 4 && prev_not_paragraph {
skip_indented_code_block(&lines, &mut i);
prev_not_paragraph = true;
continue;
}
let indent = line.len() - t.len();
if let Some((state, off)) = task_prefix_state(t, tasks) {
out.push(TaskLoc {
line: i,
state_off: indent + off, state,
});
}
prev_not_paragraph = false;
i += 1;
}
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
}
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))
}
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> {
mermaid_to_svg_reason(code, theme).ok()
}
pub fn mermaid_to_svg_reason(code: &str, theme: &str) -> Result<String, String> {
let code = code.trim_end_matches('\n');
if flowchart_is_ours(code) {
render_konoma(code, theme, crate::preview::mermaid::render::render)
} else if state_is_ours(code) {
render_konoma(code, theme, crate::preview::mermaid::render::state::render)
} else if class_is_ours(code) {
render_konoma(code, theme, crate::preview::mermaid::render::class::render)
} else if er_is_ours(code) {
render_konoma(code, theme, crate::preview::mermaid::render::er::render)
} else if sequence_is_ours(code) {
render_konoma(
code,
theme,
crate::preview::mermaid::render::sequence::render,
)
} else if let Some(draw) = chart_renderer(code) {
render_konoma(code, theme, draw)
} else if let Some(draw) = stage5b_renderer(code) {
render_konoma(code, theme, draw)
} else {
Err(format!(
"not a mermaid diagram konoma can draw: the source starts with `{}`",
crate::preview::mermaid::chart::first_word(code)
))
}
}
fn stage5b_renderer(code: &str) -> Option<Draw> {
use crate::preview::mermaid as m;
use crate::preview::mermaid::render as draw;
let caught = silence_panics(|| {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let table: &[ChartArm] = &[
(m::mindmap::is_mindmap, draw::mindmap::render),
(m::kanban::is_kanban, draw::kanban::render),
(m::journey::is_journey, draw::journey::render),
(m::timeline::is_timeline, draw::timeline::render),
(m::gantt::is_gantt, draw::gantt::render),
(
m::requirement::is_requirement_diagram,
draw::requirement::render,
),
(m::gitgraph::is_git_graph, draw::gitgraph::render),
(m::c4::is_c4, draw::c4::render),
(m::block::is_block_diagram, draw::block::render),
(m::architecture::is_architecture, draw::architecture::render),
(m::zenuml::is_zenuml, draw::zenuml::render),
];
table.iter().find(|(is, _)| is(code)).map(|(_, d)| *d)
}))
});
caught.unwrap_or(None)
}
type Draw = fn(&str, &str) -> Result<String, crate::preview::mermaid::render::RenderError>;
type ChartArm = (fn(&str) -> bool, Draw);
fn chart_renderer(code: &str) -> Option<Draw> {
use crate::preview::mermaid::chart;
use crate::preview::mermaid::render::chart as draw;
let caught = silence_panics(|| {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let table: &[ChartArm] = &[
(chart::pie::is_pie, draw::pie::render),
(chart::xychart::is_xychart, draw::xychart::render),
(chart::quadrant::is_quadrant_chart, draw::quadrant::render),
(chart::radar::is_radar, draw::radar::render),
(chart::treemap::is_treemap, draw::treemap::render),
(chart::packet::is_packet, draw::packet::render),
(chart::sankey::is_sankey, draw::sankey::render),
];
table.iter().find(|(is, _)| is(code)).map(|(_, d)| *d)
}))
});
caught.unwrap_or(None)
}
fn flowchart_is_ours(code: &str) -> bool {
let caught = silence_panics(|| {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
crate::preview::mermaid::flowchart::is_flowchart(code)
}))
});
caught.unwrap_or(false)
}
fn state_is_ours(code: &str) -> bool {
let caught = silence_panics(|| {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
crate::preview::mermaid::state::is_state_diagram(code)
}))
});
caught.unwrap_or(false)
}
fn class_is_ours(code: &str) -> bool {
let caught = silence_panics(|| {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
crate::preview::mermaid::class::is_class_diagram(code)
}))
});
caught.unwrap_or(false)
}
fn er_is_ours(code: &str) -> bool {
let caught = silence_panics(|| {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
crate::preview::mermaid::er::is_er_diagram(code)
}))
});
caught.unwrap_or(false)
}
fn sequence_is_ours(code: &str) -> bool {
let caught = silence_panics(|| {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
crate::preview::mermaid::sequence::is_sequence_diagram(code)
}))
});
caught.unwrap_or(false)
}
fn render_konoma(code: &str, theme: &str, draw: Draw) -> Result<String, String> {
let caught = silence_panics(|| {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| draw(code, theme)))
});
match caught {
Ok(Ok(svg)) => Ok(svg),
Ok(Err(e)) => Err(e.to_string()),
Err(_) => Err("konoma's mermaid renderer panicked".to_string()),
}
}
pub fn warm_mermaid() {
let _ = mermaid_to_svg("graph LR\nA-->B", "dark");
let _ = mermaid_to_svg("sequenceDiagram\n A->>B: hi", "dark");
let _ = mermaid_to_svg("pie\n \"a\" : 1", "dark");
let _ = mermaid_to_svg(
"gantt\n title G\n section S\n t :a1, 2024-01-01, 3d",
"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> {
extraction_targets(src).mermaid_fences
}
fn collect_mermaid_fences_legacy(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)> {
extraction_targets(src).math_exprs
}
fn collect_math_exprs_legacy(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,
mask: &mut Vec<bool>,
latex: &str,
display: bool,
) {
if !buf.is_empty() {
if !buf.ends_with('\n') {
mask.push(false);
}
out.push(MathPart::Text(SourceRun::new(
std::mem::take(buf),
std::mem::take(mask),
)));
} else {
debug_assert!(mask.is_empty(), "flush_math: mask without text");
}
out.push(MathPart::Math {
latex: latex.trim().to_string(),
display,
});
}
fn split_math(run: &SourceRun) -> Vec<MathPart> {
let text = run.text();
let mut out = Vec::new();
let mut buf = String::new();
let mut mask: Vec<bool> = Vec::new();
let lines: Vec<&str> = text.split_inclusive('\n').collect();
let bare_lines: Vec<&str> = lines
.iter()
.map(|l| l.strip_suffix('\n').unwrap_or(l))
.collect();
let parsed_code = run.code();
let structure = structure_mask(&bare_lines, parsed_code);
let in_code = literal_code_mask_from(parsed_code, &bare_lines);
let mut i = 0;
while i < lines.len() {
let raw = lines[i];
let bare = raw.strip_suffix('\n').unwrap_or(raw);
if in_code[i] {
buf.push_str(raw);
mask.push(parsed_code[i]);
i += 1;
continue;
}
if structure[i] != Structure::None {
buf.push_str(raw);
mask.push(parsed_code[i]);
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() {
if structure[j] != Structure::None || in_code[j] {
break;
}
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, &mut mask, &body, true);
i = j + 1; continue;
}
}
scan_inline_math(bare, &mut out, &mut buf, &mut mask);
buf.push('\n');
mask.push(false);
i += 1;
}
if !buf.is_empty() {
out.push(MathPart::Text(SourceRun::new(buf, mask)));
}
out
}
fn backtick_run_len(bytes: &[u8], i: usize) -> usize {
let mut n = 0;
while i + n < bytes.len() && bytes[i + n] == b'`' {
n += 1;
}
n
}
fn inline_code_span_end(line: &str, start: usize) -> Option<usize> {
let bytes = line.as_bytes();
let n = line.len();
let run = backtick_run_len(bytes, start);
if run == 0 {
return None;
}
let mut j = start + run;
while j < n {
if bytes[j] == b'`' {
let r = backtick_run_len(bytes, j);
j += r;
if r == run {
return Some(j);
}
} else {
j += 1;
}
}
None
}
fn next_inline_code_span(line: &str, from: usize) -> Option<(usize, usize)> {
let bytes = line.as_bytes();
let n = line.len();
let mut i = from;
while i < n {
match bytes[i] {
b'`' => match inline_code_span_end(line, i) {
Some(end) => return Some((i, end)),
None => i += backtick_run_len(bytes, i), },
b'\\' if i + 1 < n => i = (i + 1 + utf8_len(bytes[i + 1])).min(n),
b => i += utf8_len(b),
}
}
None
}
fn code_span_placeholder(n: usize) -> String {
format!("\u{0}{n}\u{0}")
}
fn mask_code_spans(line: &str) -> (String, Vec<String>) {
let mut masked = String::new();
let mut spans = Vec::new();
let mut cursor = 0;
while let Some((start, end)) = next_inline_code_span(line, cursor) {
masked.push_str(&line[cursor..start]);
masked.push_str(&code_span_placeholder(spans.len()));
spans.push(line[start..end].to_string());
cursor = end;
}
if spans.is_empty() {
return (line.to_string(), spans);
}
masked.push_str(&line[cursor..]);
(masked, spans)
}
fn rewrite_masking_code_spans(line: &str, edit: impl FnOnce(&str) -> String) -> String {
let (masked, spans) = mask_code_spans(line);
let mut out = edit(&masked);
for (i, span) in spans.iter().enumerate() {
out = out.replace(&code_span_placeholder(i), span);
}
out
}
fn scan_inline_math(line: &str, out: &mut Vec<MathPart>, buf: &mut String, mask: &mut Vec<bool>) {
let bytes = line.as_bytes();
let n = line.len();
let mut i = 0;
while i < n {
let c = bytes[i];
if c == b'`' {
let end = inline_code_span_end(line, i).unwrap_or(i + backtick_run_len(bytes, i));
buf.push_str(&line[i..end]);
i = end;
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, mask, content, false);
i = end;
continue;
}
}
b'[' => {
if let Some((content, end)) = find_close(line, i + 2, "\\]") {
flush_math(out, buf, mask, 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, mask, 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, mask, 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
}
#[derive(Debug, PartialEq)]
#[cfg(test)]
enum Segment {
Md(SourceRun),
Mermaid(String),
}
#[derive(Clone, Copy)]
struct Fence {
ch: u8,
len: usize,
}
fn parse_fence(line: &str) -> Option<(Fence, String)> {
if leading_ws_width(line) >= 4 {
return None;
}
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"))
}
#[cfg(test)]
fn split_segments(run: &SourceRun) -> Vec<Segment> {
let src = run.text();
let code = run.code();
let mut segments = Vec::new();
let mut md = String::new();
let mut md_mask: Vec<bool> = Vec::new();
let mut mermaid = String::new();
let mut open: Option<(Fence, bool)> = None;
for (i, line) in src.split_inclusive('\n').enumerate() {
let bare = line.strip_suffix('\n').unwrap_or(line);
let in_code = code.get(i).copied().unwrap_or(false);
match &open {
None => {
if let Some((fence, info)) = parse_fence(bare) {
if is_mermaid_info(&info) {
if !md.is_empty() {
segments.push(Segment::Md(SourceRun::new(
std::mem::take(&mut md),
std::mem::take(&mut md_mask),
)));
}
open = Some((fence, true));
} else {
md.push_str(line);
md_mask.push(in_code);
open = Some((fence, false));
}
} else {
md.push_str(line);
md_mask.push(in_code);
}
}
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); md_mask.push(in_code);
}
open = None;
} else if *is_mermaid {
mermaid.push_str(line);
} else {
md.push_str(line);
md_mask.push(in_code);
}
}
}
}
if !md.is_empty() {
segments.push(Segment::Md(SourceRun::new(md, md_mask)));
}
if let Some((_, true)) = open {
if !mermaid.is_empty() {
segments.push(Segment::Mermaid(mermaid));
}
}
segments
}
#[cfg(test)]
enum MdPart {
Text(SourceRun),
Table(#[allow(dead_code)] String),
}
fn fence_mask(lines: &[&str]) -> Vec<bool> {
let mut mask = vec![false; lines.len()];
let mut open: Option<Fence> = None;
for (i, line) in lines.iter().enumerate() {
match &open {
None => {
if let Some((fence, _info)) = parse_fence(line) {
mask[i] = true;
open = Some(fence);
}
}
Some(fence) => {
mask[i] = true;
let closing = parse_fence(line)
.map(|(f, info)| f.ch == fence.ch && f.len >= fence.len && info.is_empty())
.unwrap_or(false);
if closing {
open = None;
}
}
}
}
mask
}
fn code_block_mask(lines: &[&str]) -> Vec<bool> {
use pulldown_cmark::{Event, Parser, Tag};
if lines.is_empty() {
return Vec::new();
}
let text = lines.join("\n");
let mut starts = Vec::with_capacity(lines.len() + 1);
let mut pos = 0usize;
for line in lines {
starts.push(pos);
pos += line.len() + 1;
}
starts.push(pos);
let mut mask = vec![false; lines.len()];
for (ev, range) in Parser::new_ext(&text, markdown_parse_options()).into_offset_iter() {
let Event::Start(Tag::CodeBlock(_)) = ev else {
continue;
};
let hi = starts[..lines.len()].partition_point(|&s| s < range.end);
let j = starts.partition_point(|&s| s <= range.start);
let lo = j.saturating_sub(1);
if lo < hi {
mask[lo..hi].fill(true);
}
}
mask
}
fn literal_code_mask(lines: &[&str]) -> Vec<bool> {
literal_code_mask_from(&code_block_mask(lines), lines)
}
fn splitter_code_mask(lines: &[&str]) -> Vec<bool> {
code_block_mask(lines)
}
fn literal_code_mask_from(parsed: &[bool], lines: &[&str]) -> Vec<bool> {
let fenced = fence_mask(lines);
parsed.iter().zip(fenced).map(|(&a, b)| a || b).collect()
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Structure {
None,
Quote,
Details,
Table,
HtmlOpen,
HtmlBody { quoted: bool },
}
fn mark_html_block(view: &[&str], start: usize, quoted: bool, out: &mut [Structure]) -> usize {
let mut j = start + 1;
while j < view.len() && !view[j].trim().is_empty() {
j += 1;
}
out[start..j].fill(Structure::HtmlBody { quoted });
out[start] = Structure::HtmlOpen;
j
}
fn mark_nested_html_blocks(view: &[&str], in_code: &[bool], quoted: bool, out: &mut [Structure]) {
let mut k = 0;
while k < view.len() {
if !in_code[k] && is_html_block_start(view[k]) {
k = mark_html_block(view, k, quoted, out);
continue;
}
k += 1;
}
}
fn structure_mask(lines: &[&str], in_code: &[bool]) -> Vec<Structure> {
let mut mask = vec![Structure::None; lines.len()];
let mut i = 0;
while i < lines.len() {
if in_code[i] {
i += 1;
continue;
}
if is_blockquote_line(lines[i]) {
let start = i;
let mut j = start + 1;
while j < lines.len() && !in_code[j] && is_blockquote_line(lines[j]) {
j += 1;
}
mask[start..j].fill(Structure::Quote);
let body: Vec<String> = lines[start..j]
.iter()
.map(|l| strip_blockquote(l))
.collect();
let body: Vec<&str> = body.iter().map(String::as_str).collect();
mark_nested_html_blocks(&body, &in_code[start..j], true, &mut mask[start..j]);
i = j;
continue;
}
if details_open_tag(lines[i]).is_some() {
let start = i;
let close = details_block_close(lines, start);
let end = close.unwrap_or(lines.len() - 1);
mask[start..=end].fill(Structure::Details);
let body = start + 1..close.unwrap_or(lines.len());
mark_nested_html_blocks(
&lines[body.clone()],
&in_code[body.clone()],
false,
&mut mask[body],
);
i = close.map_or(lines.len(), |c| c + 1);
continue;
}
if i + 1 < lines.len()
&& !in_code[i + 1]
&& looks_like_table_row(lines[i])
&& is_table_delimiter(lines[i + 1])
{
let start = i;
let mut j = start + 2;
while j < lines.len() && !in_code[j] && looks_like_table_row(lines[j]) {
j += 1;
}
mask[start..j].fill(Structure::Table);
i = j;
continue;
}
if is_html_block_start(lines[i]) {
i = mark_html_block(lines, i, false, &mut mask);
continue;
}
i += 1;
}
mask
}
#[cfg(test)]
enum HtmlPart {
Text(SourceRun),
Html(#[allow(dead_code)] 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('/')
)
}
#[cfg(test)]
fn split_html_blocks(run: &SourceRun) -> Vec<HtmlPart> {
let lines: Vec<&str> = run.lines();
let in_code = run.code();
let mut parts = Vec::new();
let mut buf: Vec<(&str, bool)> = Vec::new();
let mut i = 0;
while i < lines.len() {
if !in_code[i] && is_html_block_start(lines[i]) {
if !buf.is_empty() {
parts.push(HtmlPart::Text(html_text_run(&mut buf)));
}
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], in_code[i]));
i += 1;
}
if !buf.is_empty() {
parts.push(HtmlPart::Text(html_text_run(&mut buf)));
}
parts
}
#[cfg(test)]
fn html_text_run(buf: &mut Vec<(&str, bool)>) -> SourceRun {
let text = buf.iter().map(|(l, _)| *l).collect::<Vec<_>>().join("\n") + "\n";
let code = buf.iter().map(|(_, c)| *c).collect();
buf.clear();
SourceRun::new(text, code)
}
const CELL_INLINE_TAGS: [(&str, &str, &str); 6] = [
("b", "**", "**"),
("strong", "**", "**"),
("i", "*", "*"),
("em", "*", "*"),
("code", "`", "`"),
("br", " ", " "),
];
fn html_cell_to_markdown(raw: &str) -> String {
let mut out = String::new();
let mut open_links: Vec<(usize, String)> = Vec::new();
let mut rest = raw;
while let Some(lt) = rest.find('<') {
if let Some(end) = html_comment_end(rest, lt) {
out.push_str(&rest[..end]);
rest = &rest[end..];
continue;
}
let Some(rel_gt) = rest[lt..].find('>') else {
break;
};
let gt = lt + rel_gt;
let tag = &rest[lt + 1..gt];
out.push_str(&rest[..lt]);
rest = &rest[gt + 1..];
let close = tag.starts_with('/');
let name: String = tag
.trim_start_matches('/')
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect();
let name = name.to_ascii_lowercase();
if name == "img" && !close {
if let Some((alt, url)) = extract_html_img(&format!("<{tag}>")) {
out.push_str(&format!(""));
continue;
}
}
if name == "a" {
if close {
if let Some((_, href)) = open_links.pop() {
out.push_str(&format!("]({href})"));
}
continue;
}
if let Some(href) = html_attr(tag, "href") {
open_links.push((out.len(), href));
out.push('[');
continue;
}
continue;
}
if let Some((_, open_md, close_md)) = CELL_INLINE_TAGS.iter().find(|(n, ..)| *n == name) {
out.push_str(if close { close_md } else { open_md });
continue;
}
out.push('<');
out.push_str(tag);
out.push('>');
}
out.push_str(rest);
for (at, _) in open_links.into_iter().rev() {
if out.is_char_boundary(at) && out[at..].starts_with('[') {
out.remove(at);
}
}
render_html_block(&out)
.iter()
.map(|l| {
l.spans
.iter()
.map(|sp| sp.content.as_ref())
.collect::<String>()
})
.filter(|l| !l.is_empty())
.collect::<Vec<String>>()
.join(" ")
}
fn html_comment_end(s: &str, at: usize) -> Option<usize> {
let body = s.get(at..)?.strip_prefix("<!--")?;
Some(match body.find("-->") {
Some(e) => at + "<!--".len() + e + "-->".len(),
None => s.len(),
})
}
pub(crate) 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..];
rest = match html_comment_end(after, 0) {
Some(end) => &after[end..],
None => match after.find('>') {
Some(e) => &after[e + 1..],
None => "",
},
};
}
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)]
pub(crate) 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}', }
}
}
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 alert_bar(color: Color) -> Span<'static> {
Span::styled("▌ ".to_string(), Style::new().fg(color))
}
fn alert_header_line(kind: AlertKind, title: &str, icons: bool) -> Line<'static> {
let color = kind.color();
let mut header = vec![alert_bar(color)];
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),
));
Line::from(header)
}
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(#[allow(dead_code)] SourceRun),
Details {
open_attr: bool,
#[allow(dead_code)]
summary: String,
#[allow(dead_code)]
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 details_block_close(lines: &[&str], start: usize) -> Option<usize> {
((start + 1)..lines.len()).find(|&j| is_details_close(lines[j]))
}
fn split_details(run: &SourceRun) -> Vec<DetailsPart> {
let lines: Vec<&str> = run.lines();
let in_code = run.code();
let mut parts = Vec::new();
let mut text = String::new();
let mut mask: Vec<bool> = Vec::new();
let mut i = 0;
while i < lines.len() {
if in_code[i] {
text.push_str(lines[i]);
text.push('\n');
mask.push(in_code[i]);
i += 1;
continue;
}
if let Some(open_attr) = details_open_tag(lines[i]) {
if !text.is_empty() {
parts.push(DetailsPart::Text(SourceRun::new(
std::mem::take(&mut text),
std::mem::take(&mut mask),
)));
}
let start = i;
let close = details_block_close(&lines, start);
let body_end = close.unwrap_or(lines.len());
let block = &lines[start + 1..body_end];
let (summary, body) = extract_summary_body(&block.join("\n"));
parts.push(DetailsPart::Details {
open_attr,
summary,
body,
});
i = close.map_or(lines.len(), |c| c + 1);
continue;
}
text.push_str(lines[i]);
text.push('\n');
mask.push(in_code[i]);
i += 1;
}
if !text.is_empty() {
parts.push(DetailsPart::Text(SourceRun::new(text, mask)));
}
parts
}
fn summary_tag_bounds(inner: &str) -> Option<(usize, usize)> {
let lower = inner.to_ascii_lowercase();
let (s, e) = (lower.find("<summary")?, lower.find("</summary>")?);
(s < e && inner[s..e].contains('>')).then_some((s, e))
}
fn extract_summary_body(inner: &str) -> (String, String) {
if let Some((s, e)) = summary_tag_bounds(inner) {
let gt = inner[s..e]
.find('>')
.expect("summary_tag_bounds confirmed a '>' in this range");
let sum = strip_inline_html_tags(inner[s + gt + 1..e].trim());
let body = trim_blank_lines(&inner[e + "</summary>".len()..]);
return (sum, body);
}
(String::new(), inner.trim().to_string())
}
fn summary_tag_end(inner: &str) -> Option<usize> {
summary_tag_bounds(inner).map(|(_, e)| e + "</summary>".len())
}
fn trim_blank_lines(s: &str) -> String {
let lines: Vec<&str> = s.lines().collect();
let start = lines.iter().position(|l| !l.trim().is_empty());
let Some(start) = start else {
return String::new();
};
let end = lines
.iter()
.rposition(|l| !l.trim().is_empty())
.unwrap_or(start);
lines[start..=end].join("\n")
}
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('▾'))
}
pub(crate) fn is_details_body_line(line: &Line<'_>) -> bool {
line.spans
.first()
.is_some_and(|s| s.content.starts_with('▏') && s.style.fg == Some(TABLE_BORDER_FG))
}
fn details_marker_line(open: bool, summary: &str, interactive: bool) -> Line<'static> {
let arrow = if open { '▾' } else { '▸' };
let label = if summary.trim().is_empty() {
"Details"
} else {
summary.trim()
};
let marker_style = if interactive {
details_marker_style()
} else {
Style::new().fg(Color::Cyan)
};
Line::from(vec![
Span::styled(format!("{arrow} "), marker_style),
Span::styled(label.to_string(), Style::new().add_modifier(Modifier::BOLD)),
])
}
fn details_bar() -> Span<'static> {
Span::styled("▏ ".to_string(), Style::new().fg(TABLE_BORDER_FG))
}
pub fn collect_details_open(src: &str) -> Vec<bool> {
split_details(&SourceRun::parse(src.to_string()))
.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
}
const BR_TAGS: [&str; 6] = ["<br>", "<br/>", "<br />", "<BR>", "<BR/>", "<BR />"];
fn next_br(s: &str) -> Option<(usize, &'static str)> {
BR_TAGS
.iter()
.filter_map(|t| s.find(t).map(|i| (i, *t)))
.min_by_key(|(i, _)| *i)
}
enum BrMode<'a> {
HtmlBody { prefix: &'a str },
Space,
Hard { prefix: &'a str },
}
fn blockquote_prefix(line: &str) -> &str {
let b = line.as_bytes();
let mut i = 0;
while i < 3 && i < b.len() && b[i] == b' ' {
i += 1;
}
if b.get(i) != Some(&b'>') {
return "";
}
let mut end = i;
while b.get(end) == Some(&b'>') {
end += 1;
if b.get(end) == Some(&b' ') {
end += 1;
}
}
&line[..end]
}
fn br_hard(s: &str, prefix: &str) -> String {
let mut out = String::new();
let mut rest = s;
while let Some((at, tag)) = next_br(rest) {
out.push_str(&rest[..at]);
rest = &rest[at + tag.len()..];
if rest.trim().is_empty() {
out.push_str(" ");
} else {
out.push_str(" \n");
out.push_str(prefix);
}
}
out.push_str(rest);
out
}
fn blank_within(line: &str, prefix: &str) -> bool {
line.strip_prefix(prefix).unwrap_or(line).trim().is_empty()
}
fn br_space(s: &str) -> String {
let mut out = String::new();
let mut rest = s;
while let Some((at, tag)) = next_br(rest) {
out.push_str(&rest[..at]);
rest = &rest[at + tag.len()..];
out.push(' ');
}
out.push_str(rest);
out
}
fn rewrite_br(s: &str, mode: &BrMode<'_>) -> String {
match mode {
BrMode::Hard { prefix } => br_hard(s, prefix),
BrMode::Space => br_space(s),
BrMode::HtmlBody { prefix } => {
let hard = br_hard(s, prefix);
let kept: Vec<&str> = hard
.split('\n')
.filter(|l| !blank_within(l, prefix))
.collect();
kept.join("\n")
}
}
}
#[cfg(test)]
pub fn process_inline_html(src: &str) -> String {
process_inline_html_traced(src, &identity_origin(src)).0
}
pub(crate) type LineOrigin = Vec<Option<usize>>;
pub(crate) fn identity_origin(src: &str) -> LineOrigin {
(0..src.lines().count()).map(Some).collect()
}
pub(crate) fn process_inline_html_traced(
src: &str,
origin_in: &[Option<usize>],
) -> (String, LineOrigin) {
let mut out = String::new();
let mut origin = LineOrigin::new();
let lines: Vec<&str> = src.lines().collect();
let parsed_code = code_block_mask(&lines);
let in_code = literal_code_mask_from(&parsed_code, &lines);
let structure = structure_mask(&lines, &parsed_code);
for (i, line) in lines.iter().enumerate() {
let line = *line;
let src_line = origin_in.get(i).copied().flatten();
if in_code[i] {
out.push_str(line);
out.push('\n');
origin.push(src_line);
continue;
}
if !line.contains('<') {
out.push_str(line);
out.push('\n');
origin.push(src_line);
continue;
}
let mode = match structure[i] {
Structure::HtmlBody { quoted } => BrMode::HtmlBody {
prefix: if quoted { blockquote_prefix(line) } else { "" },
},
Structure::Table => BrMode::Space,
Structure::None | Structure::Quote | Structure::Details | Structure::HtmlOpen => {
BrMode::Hard {
prefix: blockquote_prefix(line),
}
}
};
let s = rewrite_masking_code_spans(line, |masked| {
let mut s = replace_tag_pair(masked, "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));
rewrite_br(&s, &mode)
});
if matches!(mode, BrMode::HtmlBody { .. }) && s.is_empty() {
continue;
}
out.push_str(&s);
out.push('\n');
origin.push(src_line);
origin.extend(std::iter::repeat_n(None, s.matches('\n').count()));
}
(out, origin)
}
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()
}
struct FootnoteDef {
id: String,
text: String,
lines: std::ops::Range<usize>,
}
fn footnote_parse_options() -> ParseOptions {
let mut o = markdown_parse_options();
o.insert(ParseOptions::ENABLE_FOOTNOTES);
o
}
fn footnote_defs(lines: &[&str], in_code: &[bool]) -> Vec<FootnoteDef> {
use pulldown_cmark::{Event, Parser, Tag, TagEnd};
if lines.is_empty() {
return Vec::new();
}
let text = lines.join("\n");
let mut starts = Vec::with_capacity(lines.len() + 1);
let mut pos = 0usize;
for line in lines {
starts.push(pos);
pos += line.len() + 1;
}
starts.push(pos);
let mut out: Vec<FootnoteDef> = Vec::new();
let mut depth = 0usize;
let mut open: Option<(String, usize)> = None;
for (ev, range) in Parser::new_ext(&text, footnote_parse_options()).into_offset_iter() {
match ev {
Event::Start(Tag::FootnoteDefinition(name)) => {
if depth == 0 {
open = Some((name.to_string(), range.start));
}
depth += 1;
}
Event::End(TagEnd::FootnoteDefinition) => {
depth = depth.saturating_sub(1);
if depth > 0 {
continue;
}
let Some((id, start)) = open.take() else {
continue;
};
let lo = starts.partition_point(|&s| s <= start).saturating_sub(1);
let hi = starts[..lines.len()].partition_point(|&s| s < range.end);
if lo >= hi || hi > lines.len() {
continue;
}
let mut end = hi;
while end > lo + 1 && lines[end - 1].trim().is_empty() {
end -= 1;
}
if in_code[lo..end].iter().any(|&c| c) {
continue; }
let Some(text) = footnote_def_text(&lines[lo..end]) else {
continue;
};
out.push(FootnoteDef {
id,
text,
lines: lo..end,
});
}
_ => {}
}
}
out
}
fn footnote_def_text(block: &[&str]) -> Option<String> {
let (_, first) = parse_footnote_def(block.first()?)?;
let rest = &block[1..];
let indent = rest
.iter()
.filter(|l| !l.trim().is_empty())
.map(|l| l.chars().count() - l.trim_start().chars().count())
.min()
.unwrap_or(0);
let mut text = first;
for l in rest {
text.push('\n');
if l.trim().is_empty() {
continue; }
let line_indent = l.chars().count() - l.trim_start().chars().count();
text.push_str(strip_leading_ws_chars(l, indent.min(line_indent)));
}
Some(text)
}
fn strip_leading_ws_chars(line: &str, n: usize) -> &str {
match line.char_indices().nth(n) {
Some((idx, _)) => &line[idx..],
None => "",
}
}
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();
collect_footnote_refs(&mask_code_spans(line).0, &mut ids);
ids
}
fn collect_footnote_refs(line: &str, ids: &mut Vec<String>) {
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);
}
}
fn replace_footnote_refs(line: &str, num: &std::collections::HashMap<String, usize>) -> String {
rewrite_masking_code_spans(line, |masked| {
replace_footnote_refs_outside_code(masked, num)
})
}
fn replace_footnote_refs_outside_code(
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
}
#[cfg(test)]
pub fn process_footnotes(src: &str) -> String {
process_footnotes_traced(src, &identity_origin(src)).0
}
pub(crate) fn process_footnotes_traced(
src: &str,
origin_in: &[Option<usize>],
) -> (String, LineOrigin) {
use std::collections::HashMap;
let lines: Vec<&str> = src.lines().collect();
let in_code = literal_code_mask(&lines);
let found = footnote_defs(&lines, &in_code);
let mut defs: Vec<(String, String)> = Vec::new();
let mut is_def = vec![false; lines.len()];
for d in &found {
defs.push((d.id.clone(), d.text.clone()));
is_def[d.lines.clone()].fill(true);
}
if defs.is_empty() {
return (src.to_string(), origin_in.to_vec());
}
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;
for (i, line) in lines.iter().enumerate() {
if in_code[i] || 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(), origin_in.to_vec());
}
let mut out = String::new();
let mut origin = LineOrigin::new();
for (i, line) in lines.iter().enumerate() {
if in_code[i] {
out.push_str(line);
out.push('\n');
origin.push(origin_in.get(i).copied().flatten());
continue;
}
if is_def[i] {
continue; }
out.push_str(&replace_footnote_refs(line, &num));
out.push('\n');
origin.push(origin_in.get(i).copied().flatten());
}
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);
let body_end = out.len();
out.push_str("\n---\n\n");
for (n, text) in items {
let marker = format!("{n}. ");
let pad = " ".repeat(marker.len());
out.push_str(&marker);
for (i, line) in text.split('\n').enumerate() {
if i > 0 {
out.push('\n');
if !line.is_empty() {
out.push_str(&pad);
}
}
out.push_str(line);
}
out.push('\n');
}
origin.extend(std::iter::repeat_n(None, out[body_end..].lines().count()));
(out, origin)
}
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()
}
#[cfg(test)]
fn split_tables(run: &SourceRun) -> Vec<MdPart> {
let lines: Vec<&str> = run.lines();
let in_code = run.code();
let mut parts = Vec::new();
let mut text = String::new();
let mut mask: Vec<bool> = Vec::new();
let mut i = 0;
while i < lines.len() {
if !in_code[i]
&& i + 1 < lines.len()
&& !in_code[i + 1]
&& looks_like_table_row(lines[i])
&& is_table_delimiter(lines[i + 1])
{
if !text.is_empty() {
parts.push(MdPart::Text(SourceRun::new(
std::mem::take(&mut text),
std::mem::take(&mut mask),
)));
}
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() && !in_code[j] && 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');
mask.push(in_code[i]);
i += 1;
}
}
if !text.is_empty() {
parts.push(MdPart::Text(SourceRun::new(text, mask)));
}
parts
}
fn normalize_cell(raw: &str) -> String {
let mut out = String::new();
let mut chars = raw.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' && chars.peek() == Some(&'|') {
out.push('|');
chars.next();
} else if (c as u32) < 0x20 || c == '\u{7f}' {
out.push(' ');
} else {
out.push(c);
}
}
out.trim().to_string()
}
#[derive(Clone, Copy, PartialEq)]
enum ColAlign {
Left,
Center,
Right,
}
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, Debug)]
enum CellSeg {
Text {
text: String,
style: Style,
},
Link {
label: String,
url: String,
},
Image {
alt: String,
url: String,
},
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct CellImage {
pub url: String,
pub alt: String,
pub row: usize,
pub col: u16,
pub cols: u16,
pub rows: u16,
}
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()),
CellSeg::Image { alt, .. } => UnicodeWidthStr::width(cell_image_label(alt).as_str()),
}
}
const CELL_IMAGE_GLYPH: &str = "🖼";
fn cell_image_label(alt: &str) -> String {
let alt = alt.trim();
if alt.is_empty() {
format!("{CELL_IMAGE_GLYPH} image")
} else {
format!("{CELL_IMAGE_GLYPH} {alt}")
}
}
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_link_or_image_body(s: &str) -> Option<(usize, String, String)> {
debug_assert!(s.starts_with('['));
let close = s.find(']')?;
let after = &s[close + 1..];
let url_rest = after.strip_prefix('(')?;
let par = url_rest.find(')')?;
let label = &s[1..close];
let url = strip_link_destination(&url_rest[..par]);
if url.is_empty() {
return None;
}
let consumed = close + 2 + par + 1;
Some((consumed, label.to_string(), url))
}
fn parse_link_wrapped_image_body(s: &str) -> Option<(usize, String, String)> {
debug_assert!(s.starts_with("[!["));
let (img_consumed, alt, _img_url) = parse_link_or_image_body(&s[2..])?;
let close = 2 + img_consumed;
let after = s.get(close..)?.strip_prefix(']')?;
let url_rest = after.strip_prefix('(')?;
let par = url_rest.find(')')?;
let href = strip_link_destination(&url_rest[..par]);
if href.is_empty() {
return None;
}
let consumed = close + 2 + par + 1;
Some((consumed, alt, href))
}
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 let Some(bang_rest) = rest.strip_prefix('!') {
if bang_rest.starts_with('[') {
if let Some((consumed, alt, url)) = parse_link_or_image_body(bang_rest) {
if !text.is_empty() {
out.push(CellSeg::plain(std::mem::take(&mut text)));
}
out.push(CellSeg::Image { alt, url });
i += 1 + consumed;
continue;
}
}
} else if rest.starts_with('[') {
if rest.starts_with("[![") {
if let Some((consumed, alt, href)) = parse_link_wrapped_image_body(rest) {
if !text.is_empty() {
out.push(CellSeg::plain(std::mem::take(&mut text)));
}
out.push(CellSeg::Link {
label: cell_image_label(&alt),
url: href,
});
i += consumed;
continue;
}
}
if let Some((consumed, label, url)) = parse_link_or_image_body(rest) {
if !label.is_empty() {
if !text.is_empty() {
out.push(CellSeg::plain(std::mem::take(&mut text)));
}
out.push(CellSeg::Link { label, url });
i += consumed;
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(),
});
}
CellSeg::Image { alt, url } => {
let label = cell_image_label(alt);
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;
}
if lw > w {
let truncated = truncate_to_width(&label, w);
cur_w += UnicodeWidthStr::width(truncated.as_str());
cur.push(CellSeg::Text {
text: truncated,
style: Style::new().add_modifier(Modifier::DIM),
});
} else {
cur_w += lw;
cur.push(CellSeg::Image {
alt: alt.clone(),
url: url.clone(),
});
}
}
}
}
if !cur.is_empty() {
lines.push(cur);
}
if lines.is_empty() {
lines.push(Vec::new());
}
lines
}
fn prefix_link_icons(segs: &mut [CellSeg], icons: bool) {
if !icons {
return;
}
for seg in segs {
if let CellSeg::Link { label, .. } = seg {
if !label.starts_with(CELL_IMAGE_GLYPH) {
*label = format!("{} {label}", crate::ui::icons::link_icon());
}
}
}
}
struct TableCells {
rows: Vec<Vec<Vec<CellSeg>>>,
cell_attrs: Vec<Vec<CellAttrs>>,
header_rows: usize,
aligns: Vec<ColAlign>,
}
#[derive(Clone, Copy, Default)]
struct CellAttrs {
align: Option<ColAlign>,
header: bool,
}
fn render_table_cells(
t: &TableCells,
width: u16,
slot_of: &dyn Fn(&str, Option<u16>) -> ImageSlot,
) -> (Vec<Line<'static>>, Vec<CellImage>, u16) {
let mut rows = t.rows.clone();
let header_rows = t.header_rows;
let aligns = &t.aligns;
let ncol = rows.iter().map(|r| r.len()).max().unwrap_or(0);
if rows.is_empty() || ncol == 0 {
return (Vec::new(), Vec::new(), 0);
}
for r in &mut rows {
r.resize(ncol, Vec::new());
}
let mut natural: HashMap<(usize, usize, usize), (u16, u16)> = HashMap::new();
for (ri, r) in rows.iter().enumerate() {
for (c, cell) in r.iter().enumerate() {
for (si, seg) in cell.iter().enumerate() {
if let CellSeg::Image { url, .. } = seg {
if let ImageSlot::Inline { cols, rows } = slot_of(url, None) {
natural.insert((ri, c, si), (cols.max(1), rows.max(1)));
}
}
}
}
}
let mut col_w = vec![1usize; ncol];
for (ri, r) in rows.iter().enumerate() {
for (c, cell) in r.iter().enumerate() {
col_w[c] = col_w[c].max(cell_natural_width(cell, ri, c, &natural));
}
}
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 attrs_at = |ri: usize, c: usize| -> CellAttrs {
t.cell_attrs
.get(ri)
.and_then(|row| row.get(c))
.copied()
.unwrap_or_default()
};
let content_col =
|c: usize| -> usize { 1 + col_w[..c].iter().map(|w| w + 3).sum::<usize>() + 1 };
let mut out = Vec::new();
let mut images: Vec<CellImage> = Vec::new();
out.push(rule('┌', '┬', '┐'));
for (ri, r) in rows.iter().enumerate() {
let is_head = ri < header_rows;
let planned: Vec<(Vec<CellPlan>, Vec<CellBand>)> = r
.iter()
.enumerate()
.map(|(c, cell)| plan_cell(cell, ri, c, col_w[c], &natural, slot_of))
.collect();
let phys = planned
.iter()
.map(|(l, _)| l.len().max(1))
.max()
.unwrap_or(1);
for p in 0..phys {
let mut spans: Vec<Span<'static>> = vec![Span::styled("│", border)];
for c in 0..ncol {
let attrs = attrs_at(ri, c);
let cell_style = if is_head || attrs.header {
Style::new().fg(HEAD_FG).add_modifier(Modifier::BOLD)
} else {
Style::new()
};
let (lines_c, bands_c) = &planned[c];
let plan = lines_c.get(p);
let empty: &[CellSeg] = &[];
let segs: &[CellSeg] = match plan {
Some(CellPlan::Segs(v)) => v.as_slice(),
_ => empty,
};
let used = match plan {
Some(CellPlan::ImageRow { band, .. }) => {
(bands_c[*band].cols as usize).min(col_w[c])
}
_ => segs_width(segs),
};
let pad = col_w[c].saturating_sub(used);
let (lp, rp) = match attrs
.align
.or_else(|| 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));
if let Some(CellPlan::ImageRow { band, first }) = plan {
let b = &bands_c[*band];
let cw = (b.cols as usize).min(col_w[c]);
let text = if *first {
let label = truncate_width(&cell_image_label(&b.alt), cw);
format!("{label}{}", " ".repeat(cw.saturating_sub(label.width())))
} else {
" ".repeat(cw)
};
spans.push(Span::styled(
text,
cell_style.patch(Style::new().add_modifier(Modifier::DIM)),
));
if *first {
images.push(CellImage {
url: b.url.clone(),
alt: b.alt.clone(),
row: out.len(),
col: (content_col(c) + lp) as u16,
cols: cw as u16,
rows: b.rows,
});
}
}
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()));
}
CellSeg::Image { alt, .. } => {
spans.push(Span::styled(
cell_image_label(alt),
cell_style.patch(Style::new().add_modifier(Modifier::DIM)),
));
}
}
}
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('└', '┴', '┘'));
let drawn_width = (1 + col_w.iter().map(|w| w + 3).sum::<usize>()) as u16;
(out, images, drawn_width)
}
enum CellPlan {
Segs(Vec<CellSeg>),
ImageRow { band: usize, first: bool },
}
struct CellBand {
url: String,
alt: String,
cols: u16,
rows: u16,
}
fn cell_natural_width(
cell: &[CellSeg],
ri: usize,
c: usize,
natural: &HashMap<(usize, usize, usize), (u16, u16)>,
) -> usize {
let mut text = 0usize;
let mut widest_image = 0usize;
for (si, seg) in cell.iter().enumerate() {
match natural.get(&(ri, c, si)) {
Some(&(cols, _)) => widest_image = widest_image.max(cols as usize),
None => text += seg_width(seg),
}
}
text.max(widest_image)
}
fn plan_cell(
cell: &[CellSeg],
ri: usize,
c: usize,
w: usize,
natural: &HashMap<(usize, usize, usize), (u16, u16)>,
slot_of: &dyn Fn(&str, Option<u16>) -> ImageSlot,
) -> (Vec<CellPlan>, Vec<CellBand>) {
if !(0..cell.len()).any(|si| natural.contains_key(&(ri, c, si))) {
return (
wrap_segments(cell, w)
.into_iter()
.map(CellPlan::Segs)
.collect(),
Vec::new(),
);
}
let mut lines: Vec<CellPlan> = Vec::new();
let mut bands: Vec<CellBand> = Vec::new();
let mut run: Vec<CellSeg> = Vec::new();
for (si, seg) in cell.iter().enumerate() {
let (alt, url, nat_cols, nat_rows) = match (seg, natural.get(&(ri, c, si))) {
(CellSeg::Image { alt, url }, Some(&(nc, nr))) => (alt, url, nc, nr),
_ => {
run.push(seg.clone());
continue;
}
};
let fitted = if (nat_cols as usize) <= w {
Some((nat_cols, nat_rows))
} else {
match slot_of(url, Some(w as u16)) {
ImageSlot::Inline { cols, rows } => {
Some((cols.max(1).min(w.max(1) as u16), rows.max(1)))
}
_ => None,
}
};
let Some((cols, rows)) = fitted else {
run.push(seg.clone());
continue;
};
if !run.is_empty() {
lines.extend(wrap_segments(&run, w).into_iter().map(CellPlan::Segs));
run.clear();
}
let band = bands.len();
bands.push(CellBand {
url: url.clone(),
alt: alt.clone(),
cols,
rows,
});
for i in 0..rows as usize {
lines.push(CellPlan::ImageRow {
band,
first: i == 0,
});
}
}
if !run.is_empty() {
lines.extend(wrap_segments(&run, w).into_iter().map(CellPlan::Segs));
}
if lines.is_empty() {
lines.push(CellPlan::Segs(Vec::new()));
}
(lines, bands)
}
#[cfg(test)]
pub(crate) fn render_via_dispatcher(
src: &SourceRun,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
tasks: &[char],
alerts: bool,
) -> Vec<Line<'static>> {
let slot_of = |_: &str, _: Option<u16>| ImageSlot::Unavailable;
let mermaid_slot = |_: &str| MermaidSlot::Image { cols: 20, rows: 5 };
let math_slot = |_: &str, _: bool| MathSlot::Raw;
render_markdown_with_images(
src.text(),
width,
code,
theme,
icons,
tasks,
&slot_of,
&mermaid_slot,
"mermaid",
alerts,
&math_slot,
true,
)
.0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::DEFAULT_CODE_BG;
#[test]
fn control_characters_in_a_cell_never_shift_the_grid() {
fn terminal_width(s: &str) -> usize {
s.chars()
.map(|c| {
if (c as u32) < 0x20 || c == '\u{7f}' {
0
} else {
UnicodeWidthChar::width(c).unwrap_or(0)
}
})
.sum()
}
let payloads: Vec<(String, String)> = {
let mut v: Vec<(String, String)> = vec![
("tab in the middle".into(), "x\ty".into()),
("esc in the middle".into(), "x\u{1b}y".into()),
("nul in the middle".into(), "x\u{0}y".into()),
("del in the middle".into(), "x\u{7f}y".into()),
("leading tab".into(), "\tvalue".into()),
("trailing tab".into(), "value\t".into()),
("leading esc".into(), "\u{1b}value".into()),
("trailing esc".into(), "value\u{1b}".into()),
("several in one cell".into(), "a\tb\u{1b}c\u{7f}d".into()),
("a run of them".into(), "a\t\t\tb".into()),
("nothing but controls".into(), "\u{1b}\u{1b}".into()),
("cjk around a tab".into(), "日\t本".into()),
("cjk around an esc".into(), "日\u{1b}本".into()),
("cjk then a trailing control".into(), "日本語\u{7f}".into()),
(
"an ansi color sequence spelled out".into(),
"\u{1b}[31mred\u{1b}[0m".into(),
),
];
for b in (0x01u8..=0x08).chain([0x0b, 0x0c]).chain(0x0e..=0x1f) {
v.push((format!("c0 control {b:#04x}"), format!("x{}y", b as char)));
}
v
};
let mut bad: Vec<String> = Vec::new();
for (why, payload) in &payloads {
for (kind, src) in [
(
"gfm",
format!("| head | second |\n|------|--------|\n| {payload} | plain |\n"),
),
(
"html",
format!(
"<table>\n<tr><th>head</th><th>second</th></tr>\n\
<tr><td>{payload}</td><td>plain</td></tr>\n</table>\n"
),
),
] {
let lines = render_markdown(&src, 60, CodeStyle::default(), "TwoDark", false);
let drawn: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
for line in &drawn {
if line.chars().any(|c| (c as u32) < 0x20 || c == '\u{7f}') {
bad.push(format!(
"{kind}/{why}: a control character reached the screen: {line:?}"
));
}
}
let widths: Vec<usize> = drawn.iter().map(|l| terminal_width(l)).collect();
if widths.iter().any(|w| Some(w) != widths.first()) {
bad.push(format!(
"{kind}/{why}: the box is not a rectangle — widths {widths:?} for {drawn:?}"
));
}
}
}
assert!(
bad.is_empty(),
"control characters in a table cell broke the grid in {} case(s):\n - {}",
bad.len(),
bad.join("\n - ")
);
}
#[test]
fn blockquote_line_is_rendered_green_and_italic() {
let src = "> hello quote\n\nplain paragraph\n";
let lines = render_markdown(src, 40, CodeStyle::default(), "TwoDark", false);
let joined =
|l: &Line<'static>| -> String { l.spans.iter().map(|s| s.content.as_ref()).collect() };
let quote = lines
.iter()
.find(|l| joined(l).contains("hello quote"))
.expect("blockquote 行が描画されていない");
assert_eq!(
quote.style.fg,
Some(Color::Green),
"引用行が緑で描画されていない: {:?}",
quote.style
);
assert!(
quote.style.add_modifier.contains(Modifier::ITALIC),
"引用行が斜体で描画されていない: {:?}",
quote.style
);
let plain = lines
.iter()
.find(|l| joined(l).contains("plain paragraph"))
.expect("通常の段落行が描画されていない");
assert_ne!(
plain.style.fg,
Some(Color::Green),
"引用のスタイルが無関係な段落へ漏れている"
);
assert!(
!plain.style.add_modifier.contains(Modifier::ITALIC),
"引用のスタイルが無関係な段落へ漏れている"
);
}
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 truncate_to_width_is_cjk_aware() {
assert_eq!(truncate_to_width("hello", 3), "hel");
assert_eq!(
truncate_to_width("hello", 5),
"hello",
"ちょうど収まれば全部"
);
assert_eq!(truncate_to_width("hello", 99), "hello", "余れば全部");
assert_eq!(truncate_to_width("あいう", 3), "あ");
assert_eq!(truncate_to_width("あいう", 4), "あい");
assert_eq!(truncate_to_width("aあb", 2), "a");
assert_eq!(truncate_to_width("aあb", 3), "aあ");
assert_eq!(truncate_to_width("hi", 0), "");
}
#[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}");
}
fn rendered_texts(md: &str) -> Vec<String> {
render_markdown(md, 100, BG, "TwoDark", false)
.iter()
.map(|l| l.spans.iter().map(|sp| sp.content.as_ref()).collect())
.collect()
}
#[test]
fn a_fence_closed_by_its_container_does_not_swallow_the_rest_of_the_document() {
let cases: &[(&str, &str, &str)] = &[
(
"table after a trailing-text close in a bullet item",
"- item\n\n ```rust\n let x = 1;\n ``` ([#1](https://example.com/1))\n\n- next\n\nText.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n",
"┌",
),
(
"table after a trailing-text close in a nested bullet item",
"- outer\n - inner\n\n ```rust\n let x = 1;\n ``` (note)\n\nText.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n",
"┌",
),
(
"table after a trailing-text close in a block quote",
"> ```rust\n> let x = 1;\n> ``` (note)\n\n| a | b |\n|---|---|\n| 1 | 2 |\n",
"┌",
),
(
"table after a trailing-text close in a list item in a block quote",
"> - item\n>\n> ```rust\n> let x = 1;\n> ``` (note)\n\n| a | b |\n|---|---|\n| 1 | 2 |\n",
"┌",
),
(
"table after an entirely unclosed fence in a bullet item",
"- item\n\n ```rust\n let x = 1;\n\nText.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n",
"┌",
),
(
"alert after a trailing-text close in a bullet item",
"- item\n\n ```rust\n let x = 1;\n ``` (note)\n\nText.\n\n> [!NOTE]\n> body\n",
"▌",
),
(
"details after a trailing-text close in a bullet item",
"- item\n\n ```rust\n let x = 1;\n ``` (note)\n\nText.\n\n<details open>\n<summary>Summary here</summary>\n\nbody\n\n</details>\n",
"Summary here",
),
];
for (name, src, needle) in cases {
set_details_open(Vec::new());
let texts = rendered_texts(src);
assert!(
texts.iter().any(|t| t.contains(needle)),
"{name}: コンテナで閉じたはずのコードブロックが以降を飲み込んでいる\
({needle:?} が描画に出ない)\n--- src ---\n{src}\n--- rendered ---\n{}",
texts.join("\n")
);
}
set_details_open(Vec::new());
let texts =
rendered_texts("```rust\nlet x = 1;\n``` (note)\n\n| a | b |\n|---|---|\n| 1 | 2 |\n");
assert!(
!texts.iter().any(|t| t.contains('┌')),
"トップレベルの未閉鎖フェンスは EOF まで続くのが正しい\n--- rendered ---\n{}",
texts.join("\n")
);
}
#[test]
fn an_indented_code_block_keeps_table_and_alert_content_literal() {
for (name, src, literal) in [
(
"table",
"para\n\n | a | b |\n |---|---|\n | 1 | 2 |\n",
"| a | b |",
),
("alert", "para\n\n > [!NOTE]\n > body\n", "> [!NOTE]"),
] {
set_details_open(Vec::new());
let texts = rendered_texts(src);
let gutter = texts.iter().filter(|t| t.starts_with('▎')).count();
assert!(
gutter > 0,
"{name}: 字下げコードブロックがコードとして描かれていない\n--- rendered ---\n{}",
texts.join("\n")
);
assert!(
texts.iter().any(|t| t.contains(literal)),
"{name}: 字下げコードブロックの中身が逐語で出ていない ({literal:?})\
\n--- rendered ---\n{}",
texts.join("\n")
);
assert!(
!texts.iter().any(|t| t.contains('┌') || t.contains('▌')),
"{name}: 字下げコードブロックの中身が構造として切り出されている\
\n--- rendered ---\n{}",
texts.join("\n")
);
}
}
#[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_cell_image_renders_alt_text_not_raw_markup() {
let md = "| badge | meaning |\n|---|---|\n|  | first |\n";
let lines = render_markdown(md, 60, BG, "TwoDark", false);
let joined_all: String = lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|sp| sp.content.as_ref())
.collect();
assert!(
!joined_all.contains("![AAA]") && !joined_all.contains("a.svg"),
"生の Markdown 記法/URL が残っている: {joined_all:?}"
);
let row = lines
.iter()
.find(|l| l.spans.iter().any(|sp| sp.content.as_ref().contains("🖼")))
.expect("画像セルの行が無い");
let label = row
.spans
.iter()
.find(|sp| sp.content.as_ref().contains("🖼"))
.unwrap();
assert_eq!(label.content.as_ref(), "🖼 AAA");
assert!(
label.style.add_modifier.contains(Modifier::DIM),
"画像フォールバックは dim: {:?}",
label.style
);
let md_no_alt = "| x |\n|---|\n|  |\n";
let lines_no_alt = render_markdown(md_no_alt, 60, BG, "TwoDark", false);
let joined_no_alt: String = lines_no_alt
.iter()
.flat_map(|l| l.spans.iter())
.map(|sp| sp.content.as_ref())
.collect();
assert!(joined_no_alt.contains("🖼 image"), "{joined_no_alt:?}");
}
#[test]
fn table_image_rows_align_after_alt_fallback() {
let md = "| badge | meaning |\n|---|---|\n|  | first |\n\
|  | second |\n| plain | third |\n";
let lines = render_markdown(md, 60, BG, "TwoDark", false);
let widths: Vec<usize> = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|sp| UnicodeWidthStr::width(sp.content.as_ref()))
.sum()
})
.collect();
assert!(widths.iter().all(|w| *w == widths[0]), "{widths:?}");
}
#[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_images() {
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::Image { alt, url }] if alt == "alt" && url == "x.png"),
"画像セグメントは alt と URL の両方を保持する: {img:?}"
);
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"),
"<> 囲みは中身だけ"
);
let img_angled = parse_cell_segments("");
assert!(
matches!(&img_angled[..], [CellSeg::Image { alt, url }] if alt == "a" && url == "<./with space.png> \"Title\""),
"山括弧つき画像も URL を保持する(<> 剥がしの既知欠陥はそのまま): {img_angled:?}"
);
let img_no_alt = parse_cell_segments("");
assert!(
matches!(&img_no_alt[..], [CellSeg::Image { alt, url }] if alt.is_empty() && url == "x.png"),
"alt が空でも URL は保持する: {img_no_alt:?}"
);
let bang_only = parse_cell_segments("wow! [a](b.md)");
assert!(matches!(&bang_only[0], CellSeg::Text { text, .. } if text == "wow! "));
assert!(matches!(&bang_only[1], CellSeg::Link { label, .. } if label == "a"));
}
#[test]
fn cell_segments_recognize_link_wrapped_image_as_a_badge_link() {
let segs = parse_cell_segments("[](https://ci.example)");
assert!(
matches!(&segs[..], [CellSeg::Link { label, url }]
if label == "🖼 CI" && url == "https://ci.example"),
"{segs:?}",
);
let no_alt = parse_cell_segments("[](https://ci.example)");
assert!(
matches!(&no_alt[..], [CellSeg::Link { label, url }]
if label == "🖼 image" && url == "https://ci.example"),
"{no_alt:?}",
);
}
#[test]
fn cell_segments_leave_unsupported_nesting_unchanged() {
let segs = parse_cell_segments("[] no href here");
assert!(
matches!(&segs[0], CellSeg::Link { label, url } if label == "![alt" && url == "img.png"),
"{segs:?}",
);
}
#[test]
fn table_link_icon_does_not_double_up_on_a_badge_link() {
let mut segs = parse_cell_segments("[](https://ci.example)");
prefix_link_icons(&mut segs, true);
assert!(
matches!(&segs[..], [CellSeg::Link { label, .. }] if label == "🖼 CI"),
"{segs:?}",
);
}
#[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(&doc_run(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(&doc_run(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_via_dispatcher(
&doc_run(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, _extras) = render_markdown_with_images(
"text $x^2$ more\n",
40,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_: &str, _: Option<u16>| 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, _extras) = render_markdown_with_images(
"text $x^2$ more\n",
40,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_: &str, _: Option<u16>| 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, _extras) = render_markdown_with_images(
"text $x^2$ more\n",
40,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_: &str, _: Option<u16>| 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]
#[ignore = "known pre-existing gap — see docs/STATUS.md ★未修正 (2026-08-24, nested <details> ordinal drift)"]
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_via_dispatcher(
&doc_run(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 alert_inside_closed_details_is_not_leaked() {
let md = "<details>\n<summary>S</summary>\n\n> [!NOTE]\n\
> SECRET-INSIDE-CLOSED-DETAILS\n\n</details>\n\nTail.\n";
set_details_open(collect_details_open(md));
assert_eq!(
collect_details_open(md),
vec![false],
"one top-level (closed) details block"
);
let lines = render_via_dispatcher(
&doc_run(md),
60,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
true,
);
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
!joined.contains("SECRET-INSIDE-CLOSED-DETAILS"),
"closed <details> must not leak the alert nested inside it: {joined:?}"
);
assert!(joined.contains('S'), "summary still shows: {joined:?}");
assert!(
joined.contains("Tail."),
"trailing text survives: {joined:?}"
);
let summary_arrow = lines.iter().find_map(|l| {
let joined: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
joined
.trim_end()
.ends_with('S')
.then(|| joined.chars().next().unwrap_or(' '))
});
assert_eq!(summary_arrow, Some('▸'), "summary shows the closed marker");
}
#[test]
fn alert_inside_open_details_renders_as_a_callout() {
let md = "<details open>\n<summary>S</summary>\n\n> [!NOTE]\n\
> SECRET-INSIDE-OPEN-DETAILS\n\n</details>\n";
set_details_open(collect_details_open(md));
assert_eq!(collect_details_open(md), vec![true]);
let lines = render_via_dispatcher(
&doc_run(md),
60,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
true,
);
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
joined.contains("SECRET-INSIDE-OPEN-DETAILS"),
"open <details> shows the nested alert's body: {joined:?}"
);
assert!(
joined.contains("Note"),
"rendered as a callout with its label: {joined:?}"
);
assert!(
!joined.contains("[!NOTE]"),
"the raw `[!NOTE]` marker must be gone, not left literal: {joined:?}"
);
assert!(
lines
.iter()
.flat_map(|l| l.spans.iter())
.any(|s| s.content.contains('▌') && s.style.fg == Some(Color::Blue)),
"colored left bar (Note = blue) on the callout"
);
}
#[test]
fn details_inside_alert_closed_is_not_leaked_and_open_shows() {
let closed = "> [!NOTE]\n> <details>\n> <summary>S</summary>\n>\n\
> SECRET-C\n>\n> </details>\n\nTail.\n";
assert_eq!(
collect_details_open(closed),
Vec::<bool>::new(),
"a <details> reachable only through an alert is not in the top-level count"
);
set_details_open(collect_details_open(closed));
let lines = render_via_dispatcher(
&doc_run(closed),
60,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
true,
);
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
!joined.contains("SECRET-C"),
"closed <details> nested inside an alert must not leak: {joined:?}"
);
assert!(joined.contains("Note"), "the alert callout still shows");
assert!(joined.contains("Tail."), "trailing text survives");
assert!(
!lines
.iter()
.flat_map(|l| l.spans.iter())
.any(is_details_header_span),
"a <details> nested inside an alert must not be Tab-toggleable"
);
let open = "> [!NOTE]\n> <details open>\n> <summary>S</summary>\n>\n\
> SECRET-O\n>\n> </details>\n";
set_details_open(collect_details_open(open));
let lines_open = render_via_dispatcher(
&doc_run(open),
60,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
true,
);
let joined_open: String = lines_open
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
joined_open.contains("SECRET-O"),
"open <details> nested inside an alert shows its body: {joined_open:?}"
);
}
#[test]
fn alert_and_details_mutual_nesting_preserves_the_details_ordinal() {
let build = |a_open: &str| -> String {
format!(
"<details{a_open}>\n<summary>A</summary>\n\n> [!NOTE]\n\
> <details open>\n> <summary>Nested</summary>\n>\n\
> nested-open-body\n>\n> </details>\n\n</details>\n\n\
<details open>\n<summary>C</summary>\nc body\n</details>\n"
)
};
let closed = build("");
assert_eq!(
collect_details_open(&closed),
vec![false, true],
"top-level only: A(closed) + C(open) — Nested never counted"
);
set_details_open(collect_details_open(&closed));
let lines = render_via_dispatcher(
&doc_run(&closed),
60,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
true,
);
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
!joined.contains("nested-open-body") && !joined.contains("Nested"),
"A closed hides everything nested inside it, however deep: {joined:?}"
);
let c_arrow = lines.iter().find_map(|l| {
let joined: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
joined
.trim_end()
.ends_with('C')
.then(|| joined.chars().next().unwrap_or(' '))
});
assert_eq!(
c_arrow,
Some('▾'),
"C still honors <details open> — not shifted by whatever is nested inside A"
);
let open = build(" open");
assert_eq!(
collect_details_open(&open),
vec![true, true],
"top-level only: A(open) + C(open)"
);
set_details_open(collect_details_open(&open));
let lines_open = render_via_dispatcher(
&doc_run(&open),
60,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
true,
);
let joined_open: String = lines_open
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
joined_open.contains("nested-open-body"),
"A open + Nested open renders the deeply nested body: {joined_open:?}"
);
let c_arrow_open = lines_open.iter().find_map(|l| {
let joined: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
joined
.trim_end()
.ends_with('C')
.then(|| joined.chars().next().unwrap_or(' '))
});
assert_eq!(
c_arrow_open,
Some('▾'),
"C still reads its own (second) slot, not shifted by A's now-visible nested content"
);
}
#[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();
let marker_idx = all
.iter()
.position(|t| t == "- ")
.unwrap_or_else(|| panic!("箇条書きマーカーの行が見つからない: {all:?}"));
assert_eq!(
all.get(marker_idx + 1).map(String::as_str),
Some("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 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 task_source_locs_accepts_all_gfm_bullets() {
let src = "- [ ] dash\n* [ ] star\n+ [x] plus\n * [ ] nested star\n";
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, ' '), (1, ' '), (2, 'x'), (3, ' ')],
"3 種の箇条書き全てをタスクとして検出: {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 {} (bullet 種別に依らず正しい)",
l.line
);
}
}
#[test]
fn scan_task_lines_state_off_is_byte_exact_with_tab_indentation() {
let cases: &[(&str, &str)] = &[
(
"tab-indented checkbox inside an open <details> (the reported shape)",
"<details open>\n<summary>s</summary>\n\n- a\n\t- [ ] task\n\n</details>\n",
),
(
"tab-indented checkbox inside a top-level GitHub alert",
"> [!NOTE]\n> - a\n> \t- [ ] task\n",
),
(
"two-tab (deeper nesting) checkbox inside an open <details>",
"<details open>\n<summary>s</summary>\n\n- a\n\t- b\n\t\t- [ ] task\n\n</details>\n",
),
(
"space-then-tab-indented checkbox inside an open <details>",
"<details open>\n<summary>s</summary>\n\n- a\n \t- [ ] task\n\n</details>\n",
),
(
"tab-indented checkbox inside an alert nested in an open <details>",
"<details open>\n<summary>s</summary>\n\n> [!NOTE]\n> - a\n> \t- [ ] task\n\n</details>\n",
),
];
for (name, src) in cases {
set_details_open(Vec::new());
let locs = task_source_locs(src, &[' ', 'x'], &[]);
let lines: Vec<&str> = src.lines().collect();
let task_locs: Vec<&TaskLoc> = locs
.iter()
.filter(|l| lines[l.line].contains("task"))
.collect();
assert_eq!(
task_locs.len(),
1,
"{name}: 「task」を含む行のチェックボックスがちょうど1個見つかるはず: {:?}",
locs.iter()
.map(|l| (l.line, l.state_off, l.state))
.collect::<Vec<_>>()
);
let loc = task_locs[0];
let line = lines[loc.line];
assert!(
line.is_char_boundary(loc.state_off),
"{name}: state_off({}) が文字境界でない: {line:?}",
loc.state_off
);
assert_eq!(
line[loc.state_off..].chars().next(),
Some(loc.state),
"{name}: state_off({}) が状態文字 {:?} を指していない(行={:?})",
loc.state_off,
loc.state,
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(&doc_run(src));
assert_eq!(segs.len(), 3, "got {segs:?}");
assert!(matches!(&segs[0], Segment::Md(s) if s.text().contains("Title")));
assert!(matches!(&segs[1], Segment::Mermaid(s) if s.contains("graph TD")));
assert!(matches!(&segs[2], Segment::Md(s) if s.text().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(&doc_run(src));
assert_eq!(segs.len(), 1, "got {segs:?}");
assert!(matches!(&segs[0], Segment::Md(s) if s.text().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(&doc_run(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, _: Option<u16>| ImageSlot::Inline { cols: 10, rows: 4 };
let (_lines, imgs, _extras) = 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, _: Option<u16>| ImageSlot::Inline { cols: 20, rows: 5 };
let (lines, imgs, _extras) = 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, _: Option<u16>| ImageSlot::Unavailable; let (lines, imgs, _extras) = 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, _: Option<u16>| ImageSlot::Loading;
let (lines, imgs, _extras) = 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_includes_table_cell_images() {
let gfm = "| a |\n|---|\n|  |\n";
assert_eq!(
collect_remote_image_urls(gfm),
vec!["https://example.com/cell.png".to_string()],
"GFM 表のセル画像がダウンロード対象に入っていない"
);
let html =
"<table><tr><td><img src=\"https://example.com/h.png\" alt=\"H\"></td></tr></table>\n";
assert_eq!(
collect_remote_image_urls(html),
vec!["https://example.com/h.png".to_string()],
"HTML 表のセル画像がダウンロード対象に入っていない"
);
let local = "| a |\n|---|\n|  |\n";
assert!(collect_remote_image_urls(local).is_empty());
}
#[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_themes_change_colours_but_never_font_metrics() {
use crate::preview::mermaid::text_metrics::{FONT_FAMILY, FONT_SIZE};
let svg = mermaid_to_svg("graph LR\nA-->B", "dark").unwrap();
assert!(
svg.contains(&format!("font-family=\"{FONT_FAMILY}\"")),
"描画に指定するフォントは計測に使ったフォントと同じであるべき: {svg}"
);
assert!(
svg.contains(&format!("font-size=\"{}\"", FONT_SIZE as i32)),
"描画のフォントサイズも計測値に固定されているべき: {svg}"
);
let geometry_of = |s: &str| {
s.split_whitespace()
.filter(|w| !w.starts_with("fill=") && !w.starts_with("stroke=\""))
.collect::<Vec<_>>()
.join(" ")
};
let base = geometry_of(&svg);
for name in ["light", "modern", "classic", "mermaid", "forest", "neutral"] {
let other = mermaid_to_svg("graph LR\nA-->B", name).unwrap();
assert_eq!(
base,
geometry_of(&other),
"テーマ {name} が色以外(=配置)を動かした"
);
}
}
#[test]
fn mermaid_to_svg_fails_safely_on_garbage() {
assert!(mermaid_to_svg("definitely not a diagram !!!", "dark").is_none());
}
#[test]
fn a_diagram_invented_from_a_meaningless_line_is_not_drawn() {
let src = "erDiagram\n p[Person] ||--o| a[\"Customer Account\"] : has";
assert!(mermaid_to_svg(src, "dark").is_none());
let reason = mermaid_to_svg_reason(src, "dark").unwrap_err();
assert!(
reason.contains("names an entity's alias"),
"the reason should name the cause; got {reason:?}"
);
}
#[test]
fn every_diagram_comes_from_konomas_renderer() {
let drawn_by_konoma = |code: &str| -> bool {
let svg = mermaid_to_svg(code, "dark").expect("renders");
svg.contains("font-family=\"sans-serif\"") && !svg.contains("Inter")
};
for code in [
"flowchart TD\n A[Start] --> B{Ok?}",
"graph LR\n A --> B",
"flowchart-elk LR\n A --> B",
" \n%% a comment first\nflowchart LR\n A --> B",
"---\ntitle: t\n---\nflowchart LR\n A --> B",
] {
assert!(drawn_by_konoma(code), "自作レンダラが描くはず: {code:?}");
}
for code in [
"stateDiagram-v2\n [*] --> S1\n S1 --> [*]",
"stateDiagram\n A --> B",
" \n%% a comment first\nstateDiagram-v2\n [*] --> A",
"---\ntitle: t\n---\nstateDiagram-v2\n [*] --> A",
] {
assert!(
drawn_by_konoma(code),
"自作レンダラが描くはず(段2): {code:?}"
);
}
for code in [
"classDiagram\n class A\n class B\n A --> B",
"classDiagram-v2\n Animal <|-- Duck",
" \n%% a comment first\nclassDiagram\n class A\n A : +x",
"---\ntitle: t\n---\nclassDiagram\n class A\n A : +x",
] {
assert!(
drawn_by_konoma(code),
"自作レンダラが描くはず(段3・クラス図): {code:?}"
);
}
for code in [
"erDiagram\n CUSTOMER ||--o{ ORDER : places",
"ERDIAGRAM\n A ||--|| B : x",
" \n%% a comment first\nerDiagram\n A ||--|| B : x",
"---\ntitle: t\n---\nerDiagram\n A ||--|| B : x",
] {
assert!(
drawn_by_konoma(code),
"自作レンダラが描くはず(段3・ER図): {code:?}"
);
}
for code in [
"sequenceDiagram\n A->>B: hi",
"SEQUENCEDIAGRAM\n A->>B: hi",
" \n%% a comment first\nsequenceDiagram\n A->>B: hi",
"---\ntitle: t\n---\nsequenceDiagram\n A->>B: hi",
] {
assert!(
drawn_by_konoma(code),
"自作レンダラが描くはず(段4・シーケンス図): {code:?}"
);
}
for code in [
"mindmap\n root((r))\n a",
"kanban\n Todo\n a",
"journey\n Task: 3: Me",
"timeline\n 2002 : LinkedIn",
"gantt\n title G\n section S\n task :a1, 2024-01-01, 3d",
"requirementDiagram\n requirement r {\n id: 1\n }",
"gitGraph\n commit",
"C4Context\n System(a, \"A\")",
"block-beta\n a b c",
"architecture-beta\n service a(x)[A]",
"zenuml\n A->B: hi",
] {
assert!(
drawn_by_konoma(code),
"自作レンダラが描くはず(段5b): {code:?}"
);
}
}
#[test]
fn no_diagram_kind_stopped_rendering_when_the_flowchart_renderer_landed() {
for code in [
"flowchart TD\n A --> B",
"graph LR\n A --> B",
"sequenceDiagram\n A->>B: hi",
"classDiagram\n class A\n class B\n A --> B",
"erDiagram\n CUSTOMER ||--o{ ORDER : places",
"pie title P\n \"a\" : 10\n \"b\" : 20",
"stateDiagram-v2\n [*] --> S1\n S1 --> [*]",
"stateDiagram\n [*] --> S1\n S1 --> [*]",
"stateDiagram-v2\n state S { [*] --> a }\n [*] --> S",
"stateDiagram-v2\n state c <<choice>>\n A --> c\n c --> B",
"stateDiagram-v2\n state f <<fork>>\n A --> f\n f --> B\n f --> C",
"stateDiagram-v2\n A --> B\n note right of A : hi",
"stateDiagram-v2\n state S {\n A --> B\n --\n C --> D\n }",
"gantt\n title G\n section S\n task :a1, 2024-01-01, 3d",
"journey\n title J\n section S\n Do: 5: Me",
"classDiagram\n class BankAccount\n BankAccount : +String owner",
"classDiagram\n class BankAccount{\n +String owner\n +deposit(amount) bool\n }",
"classDiagram\n classA <|-- classB\n classC *-- classD\n classE o-- classF",
"classDiagram\n Customer \"1\" --> \"*\" Ticket",
"classDiagram\n class Shape <<interface>>\n Shape : draw()",
"classDiagram\n namespace BaseShapes {\n class Triangle\n }",
"classDiagram\n note \"a note\"\n class MyClass{\n }",
"classDiagram\n bar ()-- foo",
"classDiagram\n direction RL\n class A\n A : +x",
"erDiagram\n CUSTOMER ||--o{ ORDER : places\n CUSTOMER {\n string name PK\n }",
"erDiagram\n CAR 1 to zero or more NAMED-DRIVER : allows",
"erDiagram\n p[Person] {\n string firstName\n }\n a[\"Customer Account\"] {\n string email\n }\n p ||--o| a : has",
"erDiagram\n subgraph title1\n CUSTOMER\n end",
"erDiagram\n direction LR\n A ||--|| B : x",
"sequenceDiagram\n Alice->>John: Hello John, how are you?\n John-->>Alice: Great!\n Alice-)John: See you later!",
"sequenceDiagram\n participant Alice\n participant Bob\n Bob->>Alice: Hi Alice",
"sequenceDiagram\n actor Alice\n actor Bob\n Alice->>Bob: Hi Bob",
"sequenceDiagram\n participant A as Alice\n A->>J: Hello",
"sequenceDiagram\n participant Alice@{ \"type\" : \"boundary\" }\n Alice->>Bob: Request",
"sequenceDiagram\n Alice->>+John: Hello\n John-->>-Alice: Great!",
"sequenceDiagram\n Alice->>John: Hello\n activate John\n John-->>Alice: Great!\n deactivate John",
"sequenceDiagram\n participant John\n Note right of John: Text in note",
"sequenceDiagram\n Alice->John: Hi\n Note over Alice,John: A typical interaction",
"sequenceDiagram\n Alice->John: Hi\n loop Every minute\n John-->Alice: Great!\n end",
"sequenceDiagram\n alt is sick\n Bob->>Alice: Not so good\n else is well\n Bob->>Alice: Fresh\n end",
"sequenceDiagram\n opt Extra response\n Bob->>Alice: Thanks\n end",
"sequenceDiagram\n par a\n A->>B: x\n and b\n A->>C: y\n end",
"sequenceDiagram\n critical connect\n S-->DB: connect\n option timeout\n S-->S: log\n end",
"sequenceDiagram\n break it failed\n API-->Consumer: show failure\n end",
"sequenceDiagram\n rect rgb(191, 223, 255)\n A->>B: x\n end",
"sequenceDiagram\n autonumber\n A->>B: x",
"sequenceDiagram\n autonumber 10 5\n A->>B: x",
"sequenceDiagram\n box Purple Alice & John\n participant A\n participant J\n end\n A->>J: x",
"sequenceDiagram\n A->>B: one\n create participant C\n A->>C: two\n destroy C\n A-xC: three",
"sequenceDiagram\n title Checkout\n A->>B: x",
"sequenceDiagram\n participant A\n link A: Dash @ https://x.test/a\n A->>B: x",
"sequenceDiagram\n A->>A: retry",
"sequenceDiagram\n A<<->>B: both ways\n A-xB: cross\n A-)B: async",
"pie title Pets adopted by volunteers\n \"Dogs\" : 386\n \"Cats\" : 85",
"pie showData\n title Key elements\n \"Calcium\" : 42.96\n \"Iron\" : 5",
"xychart-beta\n title \"Sales Revenue\"\n x-axis [jan, feb, mar]\n \
y-axis \"Revenue (in $)\" 4000 --> 11000\n bar [5000, 6000, 7500]\n \
line [5000, 6000, 7500]",
"xychart\n bar [1, 2, 3]",
"xychart-beta horizontal\n bar [1, 2, 3]",
"quadrantChart\n x-axis Low Reach --> High Reach\n \
y-axis Low Engagement --> High Engagement\n quadrant-1 We should expand\n \
Campaign A: [0.3, 0.6]",
"quadrantChart\n classDef hot color: #ff0000\n A:::hot: [0.2, 0.3]",
"radar-beta\n axis a[\"Math\"], b[\"Science\"], c[\"English\"]\n \
curve x[\"Alice\"]{85, 90, 80}\n max 100",
"radar-beta\n axis a, b, c\n curve x{c: 3, a: 1, b: 2}\n graticule polygon",
"treemap-beta\n\"Section 1\"\n \"Leaf 1.1\": 12\n\"Section 2\"\n \
\"Leaf 2.1\": 20",
"treemap\n\"a\": 1\n\"b\": 2",
"packet-beta\n0-15: \"Source Port\"\n16-31: \"Destination Port\"\n\
32-63: \"Sequence Number\"",
"packet\n+8: \"a\"\n+8: \"b\"",
"sankey-beta\n\nAgricultural 'waste',Bio-conversion,124.729\n\
Bio-conversion,Liquid,0.597\nBio-conversion,Solid,280.322",
"sankey\na,b,1\nb,c,2",
"mindmap\n root((mindmap))\n Origins\n Long history",
"mindmap\nRoot\n A\n B\n C",
"mindmap\n id[I am a square]",
"mindmap\n id(I am a rounded square)",
"mindmap\n id((I am a circle))",
"mindmap\n id))I am a bang((",
"mindmap\n id)I am a cloud(",
"mindmap\n id{{I am a hexagon}}",
"mindmap\n I am the default shape",
"mindmap\n Root\n A\n ::icon(fa fa-book)\n B(B)",
"mindmap\n Root\n A[A]\n :::urgent large\n B(B)",
"mindmap\nRoot\n A\n B\n C",
"mindmap\n id1[\"`**Root** with\na second line`\"]",
"kanban\n Todo\n [Create Documentation]",
"kanban\n id5[Ready]\n id6[Task]@{ ticket: MC-2037, assigned: 'knsv', priority: 'Very High' }",
"kanban\n Todo\n Doing\n a",
"journey\n title My working day\n section Go to work\n Make tea: 5: Me\n Do work: 1: Me, Cat",
"journey\n Alone: 2",
"timeline\n title History\n 2002 : LinkedIn\n 2004 : Facebook\n : Google",
"timeline\n 2004 : Facebook : Google",
"timeline\n section 17th century\n Industry 1.0 : Steam",
"timeline TD\n section Q1\n Bullet 1 : sub-point 1a",
"gantt\n title A Gantt Diagram\n dateFormat YYYY-MM-DD\n section Section\n A task :a1, 2014-01-01, 30d\n Another :after a1, 20d",
"gantt\n dateFormat YYYY-MM-DD\n a :done, des1, 2014-01-06, 2014-01-08\n b :active, des2, 2014-01-09, 3d\n c :crit, des3, after des2, 24h\n d :milestone, m1, 2014-01-25, 0d",
"gantt\n dateFormat YYYY-MM-DD\n excludes weekends\n a :2024-01-01, 10d",
"gantt\n dateFormat YYYY-MM-DD\n axisFormat %d/%m\n tickInterval 1week\n todayMarker off\n weekday monday\n a :2024-01-01, 10d",
"gantt\n dateFormat YYYY-MM-DD\n inclusiveEndDates\n topAxis\n a :2024-01-01, 2024-01-03",
"gantt\n dateFormat YYYY-MM-DD\n a :des1, 2024-01-01, 1d\n click des1 href \"https://x.test/\"",
"requirementDiagram\n requirement test_req {\n id: 1\n text: the test text.\n risk: high\n verifymethod: test\n }\n element test_entity {\n type: simulation\n }\n test_entity - satisfies -> test_req",
"requirementDiagram\n functionalRequirement a {\n id: 1\n }\n interfaceRequirement b {\n id: 2\n }\n performanceRequirement c {\n id: 3\n }\n physicalRequirement d {\n id: 4\n }\n designConstraint e {\n id: 5\n }\n a - contains -> b",
"requirementDiagram\n direction LR\n requirement a {\n id: 1\n }\n requirement b {\n id: 2\n }\n a <- derives - b",
"gitGraph\n commit\n commit\n branch develop\n checkout develop\n commit\n checkout main\n merge develop",
"gitGraph\n commit id: \"Alpha\"\n commit id: \"Reverse\" type: REVERSE tag: \"RC_1\"\n commit id: \"Highlight\" type: HIGHLIGHT",
"gitGraph LR:\n commit\n branch b order: 2\n commit",
"gitGraph:\n commit\n branch a\n switch a\n commit",
"gitGraph\n commit id: \"ZERO\"\n branch develop\n commit id:\"B\"\n checkout main\n merge develop id:\"MERGE\"\n branch release\n cherry-pick id:\"MERGE\" parent:\"B\"",
"C4Context\n title Context\n Enterprise_Boundary(b0, \"Bank\") {\n Person(a, \"Customer\", \"desc\")\n System(s, \"System\", \"desc\")\n }\n Rel(a, s, \"Uses\")",
"C4Container\n Container(c, \"Web\", \"Java\", \"desc\")\n ContainerDb(d, \"DB\", \"SQL\")\n ContainerQueue(q, \"Events\", \"Kafka\")\n Rel(c, d, \"Reads\", \"JDBC\")",
"C4Component\n Component(c, \"Controller\", \"Spring\")\n Component_Ext(e, \"External\")\n BiRel(c, e, \"talks to\")",
"C4Dynamic\n System(a, \"A\")\n System(b, \"B\")\n RelIndex(\"1\", a, b, \"first\")",
"C4Deployment\n Deployment_Node(dn, \"Host\", \"Ubuntu\") {\n Container(api, \"API\", \"Java\")\n }\n Person(u, \"User\")\n Rel(u, api, \"Uses\", \"HTTPS\")",
"C4Context\n System(a, \"A\")\n UpdateElementStyle(a, $fontColor=\"red\")\n UpdateLayoutConfig($c4ShapeInRow=\"3\")",
"block-beta\n a b c",
"block-beta\n columns 3\n a[\"A label\"] b:2 c:2 d",
"block-beta\n columns 3\n a:3\n block:group1:2\n columns 2\n h i j k\n end\n g",
"block-beta\n block\n D\n end\n A[\"A wide one\"]",
"block-beta\n id1((\"circle\"))\n id2([\"stadium\"])\n id3[[\"subroutine\"]]\n id4[(\"db\")]\n id5>\"odd\"]\n id6{\"diamond\"}\n id7{{\"hex\"}}\n id8[/\"lean\"/]\n id9[\\\"lean\"\\]\n ida[/\"trap\"\\]\n idb[\\\"inv\"/]\n idc(((\"double\")))",
"block-beta\n blockArrowId<[\"Label\"]>(right)\n blockArrowId7<[\"Label\"]>(x, down)",
"block-beta\n columns 3\n a space b\n c d e",
"block-beta\n A space:2 B\n A-- \"X\" -->B",
"block-beta\n id1 space id2\n id1(\"Start\")-->id2(\"Stop\")\n style id1 fill:#636",
"architecture-beta\n group api(cloud)[API]\n service db(database)[Database] in api\n service server(server)[Server] in api\n db:L -- R:server",
"architecture-beta\n service a(server)[A]\n service b(server)[B]\n a:R --> L:b",
"architecture-beta\n service a(server)[A]\n junction j\n a:R -- L:j",
"architecture-beta\n service a(server)[A]\n service b(server)[B]\n service c(server)[C]\n a:B --> T:c\n b:B --> T:c\n align row a b",
"architecture-beta\n group g(cloud)[G]\n service a(server)[A] in g\n service b(server)[B]\n a{group}:R --> L:b",
"zenuml\n title Demo\n Alice->John: Hello\n John->Alice: Great!",
"zenuml\n @Actor Alice\n @Database Bob\n Alice->Bob: Hi",
"zenuml\n A as Alice\n J as John\n A->J: Hello",
"zenuml\n A.SyncMessage\n A.SyncMessage(with, parameters) {\n B.nestedSyncMessage()\n }",
"zenuml\n new A1\n new A2(with, parameters)",
"zenuml\n a = A.SyncMessage()\n A.SyncMessage() {\n return result\n }\n @return\n A->B: result",
"zenuml\n Alice->Bob: how are you?\n if(is_sick) {\n Bob->Alice: Not so good\n } else {\n Bob->Alice: Fresh\n }",
"zenuml\n Alice->John: Hello\n while(true) {\n John->Alice: Great!\n }",
"zenuml\n opt {\n Bob->Alice: Thanks\n }",
"zenuml\n par {\n Alice->Bob: Hello\n Alice->John: Hello\n }",
"zenuml\n try {\n Consumer->API: Book\n } catch {\n API->Consumer: fail\n } finally {\n API->Service: rollback\n }",
] {
assert!(
mermaid_to_svg(code, "dark").is_some(),
"図種が描けなくなった(=退行): {code:?}"
);
}
}
#[test]
fn the_seven_data_charts_come_from_konomas_renderer() {
let drawn_by_konoma = |code: &str| -> bool {
let svg =
mermaid_to_svg(code, "dark").unwrap_or_else(|| panic!("should render: {code:?}"));
svg.contains("font-family=\"sans-serif\"") && !svg.contains("Inter")
};
for code in [
"pie\n \"a\" : 1",
"pie showData\n \"a\" : 1",
"xychart-beta\n bar [1, 2]",
"xychart\n bar [1, 2]",
"quadrantChart\n A: [0.5, 0.5]",
"radar-beta\n axis a, b, c\n curve x{1, 2, 3}",
"treemap-beta\n\"a\": 1",
"treemap\n\"a\": 1",
"packet-beta\n0-7: \"a\"",
"packet\n0-7: \"a\"",
"sankey-beta\na,b,1",
"sankey\na,b,1",
" \n%% a comment first\npie\n \"a\" : 1",
"---\ntitle: t\n---\npie\n \"a\" : 1",
] {
assert!(
drawn_by_konoma(code),
"自作レンダラが描くはず(段5・データチャート): {code:?}"
);
}
for code in [
"gantt\n title G\n section S\n task :a1, 2024-01-01, 3d",
"journey\n title J\n section S\n Do: 5: Me",
"timeline\n title T\n 2002 : LinkedIn",
"mindmap\n root((r))\n a",
"gitGraph\n commit\n commit",
] {
assert!(
drawn_by_konoma(code),
"自作レンダラが描くはず(段5b): {code:?}"
);
}
}
#[test]
fn a_refused_chart_is_not_quietly_redrawn_by_the_crate() {
for (code, reason) in [
("pie", "pie declares no slice"),
("xychart-beta", "xychart declares no plot"),
("quadrantChart", "quadrantChart declares no point or axis"),
("radar-beta", "radar-beta declares no axis"),
("treemap-beta", "treemap declares no node"),
("packet-beta", "packet declares no field"),
("sankey-beta", "sankey declares no link"),
] {
assert_eq!(
mermaid_to_svg_reason(code, "dark"),
Err(reason.to_string()),
"{code:?} は自作が拒否し、その理由がそのまま出るはず"
);
assert!(mermaid_to_svg(code, "dark").is_none(), "{code:?}");
}
for (code, reason) in [
(
"pie\n \"a\" : 0\n \"b\" : 0",
"every slice is zero, so no slice has a share",
),
(
"radar-beta\n axis a, b\n curve x{1, 2}",
"a radar chart needs at least three axes",
),
(
"treemap-beta\n\"a\": 0",
"every value is zero, so no tile has any area",
),
] {
assert_eq!(mermaid_to_svg_reason(code, "dark"), Err(reason.to_string()));
}
}
#[test]
fn the_chart_routing_predicates_agree_with_their_parsers() {
use crate::preview::mermaid::chart::{self, ParseError};
type Pair = (fn(&str) -> bool, fn(&str) -> Result<(), ParseError>);
let pairs: &[(&str, Pair)] = &[
(
"pie",
(chart::pie::is_pie, |s| chart::pie::parse(s).map(|_| ())),
),
(
"xychart",
(chart::xychart::is_xychart, |s| {
chart::xychart::parse(s).map(|_| ())
}),
),
(
"quadrant",
(chart::quadrant::is_quadrant_chart, |s| {
chart::quadrant::parse(s).map(|_| ())
}),
),
(
"radar",
(chart::radar::is_radar, |s| {
chart::radar::parse(s).map(|_| ())
}),
),
(
"treemap",
(chart::treemap::is_treemap, |s| {
chart::treemap::parse(s).map(|_| ())
}),
),
(
"packet",
(chart::packet::is_packet, |s| {
chart::packet::parse(s).map(|_| ())
}),
),
(
"sankey",
(chart::sankey::is_sankey, |s| {
chart::sankey::parse(s).map(|_| ())
}),
),
];
let sources = [
"pie\n \"a\" : 1",
"pie",
"pieces\n a",
"xychart-beta\n bar [1]",
"xychart",
"quadrantChart\n A: [0, 0]",
"quadrantChart",
"radar-beta\n axis a,b,c\n curve x{1,2,3}",
"radar-beta",
"treemap\n\"a\": 1",
"treemap-beta",
"packet-beta\n0: \"a\"",
"packet",
"sankey-beta\na,b,1",
"sankey",
"flowchart TD\n A --> B",
"sequenceDiagram\n A->>B: hi",
"gantt\n title G",
"",
" \n\n",
"%% only a comment\n",
];
for (name, (is_ours, parse)) in pairs {
for code in sources {
let parser_says_not_ours =
matches!(parse(code), Err(ParseError::NotThisChart { .. }));
assert_eq!(
is_ours(code),
!parser_says_not_ours,
"{name}: 振り分けとパーサの見解が食い違った: {code:?}"
);
}
}
}
#[test]
fn a_refused_flowchart_degrades_instead_of_being_drawn() {
assert_eq!(
mermaid_to_svg_reason("graph TD", "dark"),
Err("flowchart declares no nodes".to_string())
);
assert!(mermaid_to_svg("graph TD", "dark").is_none());
}
#[test]
fn a_refused_state_diagram_degrades_instead_of_being_drawn() {
assert_eq!(
mermaid_to_svg_reason("stateDiagram-v2\n", "dark"),
Err("state diagram declares no states".to_string())
);
assert!(mermaid_to_svg("stateDiagram-v2\n", "dark").is_none());
}
#[test]
fn the_state_routing_predicate_agrees_with_the_parser() {
use crate::preview::mermaid::state::{is_state_diagram, parse, ParseError};
for code in [
"stateDiagram-v2\n A --> B",
"stateDiagram\n A --> B",
"stateDiagram-v2",
"stateDiagram-v2\n state S {\n A --> B",
"flowchart TD\n A --> B",
"sequenceDiagram\n A->>B: hi",
"stateDiagrams --> B",
"",
" \n\n",
"%% only a comment\n",
] {
let parser_says_not_ours = matches!(
parse(code),
Err(ParseError::NotAStateDiagram { .. }) | Err(ParseError::Empty)
);
assert_eq!(
is_state_diagram(code),
!parser_says_not_ours,
"振り分けとパーサの見解が食い違った: {code:?}"
);
}
}
#[test]
fn a_refused_class_diagram_degrades_instead_of_being_drawn() {
assert_eq!(
mermaid_to_svg_reason("classDiagram\n", "dark"),
Err("class diagram declares no classes".to_string())
);
assert!(mermaid_to_svg("classDiagram\n", "dark").is_none());
}
#[test]
fn a_refused_er_diagram_degrades_instead_of_being_drawn() {
assert_eq!(
mermaid_to_svg_reason("erDiagram\n", "dark"),
Err("ER diagram declares no entities".to_string())
);
assert!(mermaid_to_svg("erDiagram\n", "dark").is_none());
}
#[test]
fn the_class_routing_predicate_agrees_with_the_parser() {
use crate::preview::mermaid::class::{is_class_diagram, parse, ParseError};
for code in [
"classDiagram\n A --> B",
"classDiagram-v2\n A --> B",
"classDiagram",
"classDiagram\n class A {\n +x",
"flowchart TD\n A --> B",
"stateDiagram-v2\n [*] --> A",
"erDiagram\n A ||--|| B : x",
"sequenceDiagram\n A->>B: hi",
"classDiagrams --> B",
"",
" \n\n",
"%% only a comment\n",
] {
let parser_says_not_ours = matches!(
parse(code),
Err(ParseError::NotAClassDiagram { .. }) | Err(ParseError::Empty)
);
assert_eq!(
is_class_diagram(code),
!parser_says_not_ours,
"振り分けとパーサの見解が食い違った: {code:?}"
);
}
}
#[test]
fn the_er_routing_predicate_agrees_with_the_parser() {
use crate::preview::mermaid::er::{is_er_diagram, parse, ParseError};
for code in [
"erDiagram\n A ||--|| B : x",
"ERDIAGRAM\n A ||--|| B : x",
"erdiagram\n A",
"erDiagram",
"erDiagram\n A {\n string x",
"flowchart TD\n A --> B",
"classDiagram\n A --> B",
"stateDiagram-v2\n [*] --> A",
"erDiagrams --> B",
"",
" \n\n",
"%% only a comment\n",
] {
let parser_says_not_ours = matches!(
parse(code),
Err(ParseError::NotAnErDiagram { .. }) | Err(ParseError::Empty)
);
assert_eq!(
is_er_diagram(code),
!parser_says_not_ours,
"振り分けとパーサの見解が食い違った: {code:?}"
);
}
}
#[test]
fn a_refused_sequence_diagram_degrades_instead_of_being_drawn() {
assert_eq!(
mermaid_to_svg_reason("sequenceDiagram\n", "dark"),
Err("sequence diagram declares no participants".to_string())
);
assert!(mermaid_to_svg("sequenceDiagram\n", "dark").is_none());
for (src, reason) in [
(
"sequenceDiagram\n A->>B: x\n deactivate B",
"`B` is not active at line 3",
),
(
"sequenceDiagram\n A->>B: x\n create participant B",
"`B` already exists and cannot be created at line 3",
),
] {
assert_eq!(
mermaid_to_svg_reason(src, "dark"),
Err(reason.to_string()),
"{src:?}"
);
}
}
#[test]
fn the_sequence_routing_predicate_agrees_with_the_parser() {
use crate::preview::mermaid::sequence::{is_sequence_diagram, parse, ParseError};
for code in [
"sequenceDiagram\n A->>B: hi",
"SEQUENCEDIAGRAM\n A->>B: hi",
"sequencediagram\n A->>B: hi",
"sequenceDiagram",
"sequenceDiagram\n loop x\n A->>B: y",
"sequenceDiagram\n A->>B: x\n deactivate B",
"flowchart TD\n A --> B",
"classDiagram\n A --> B",
"erDiagram\n A ||--|| B : x",
"stateDiagram-v2\n [*] --> A",
"sequenceDiagrams --> B",
"",
" \n\n",
"%% only a comment\n",
] {
let parser_says_not_ours = matches!(
parse(code),
Err(ParseError::NotASequenceDiagram { .. }) | Err(ParseError::Empty)
);
assert_eq!(
is_sequence_diagram(code),
!parser_says_not_ours,
"振り分けとパーサの見解が食い違った: {code:?}"
);
}
}
#[test]
fn a_refusal_says_what_was_wrong() {
assert_eq!(
mermaid_to_svg_reason("definitely not a diagram !!!", "dark").unwrap_err(),
"not a mermaid diagram konoma can draw: the source starts with `definitely`",
"どの図種にも当たらないソースは、その旨がそのまま出る"
);
assert_eq!(
mermaid_to_svg_reason("flowchart LR\n A[\"unclosed", "dark").unwrap_err(),
"unclosed `\"` at line 2"
);
assert_eq!(
mermaid_to_svg_reason("flowchart LR\n A@{ shape: nope }", "dark").unwrap_err(),
"no such shape: `nope` at line 2 (shape names are lowercase and use `-`)"
);
}
#[test]
fn the_routing_predicate_agrees_with_the_parser() {
use crate::preview::mermaid::flowchart::{is_flowchart, parse, ParseError};
for code in [
"flowchart TD\n A --> B",
"graph LR\n A --> B",
"graph TD",
"flowchart LR\n A[\"unclosed",
"sequenceDiagram\n A->>B: hi",
"graphs --> B",
"",
" \n\n",
"%% only a comment\n",
] {
let parser_says_not_ours = matches!(
parse(code),
Err(ParseError::NotAFlowchart { .. }) | Err(ParseError::Empty)
);
assert_eq!(
is_flowchart(code),
!parser_says_not_ours,
"振り分けとパーサの見解が食い違った: {code:?}"
);
}
}
#[test]
fn the_replaced_renderer_is_no_longer_a_dependency() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let manifest = std::fs::read_to_string(root.join("Cargo.toml")).expect("Cargo.toml");
for line in manifest.lines() {
let l = line.trim();
if l.starts_with('#') {
continue;
}
assert!(
!l.contains("mermaid-rs-renderer"),
"Cargo.toml still declares the crate stage 5b removed: {l}"
);
}
let mut offenders: Vec<String> = Vec::new();
let mut stack = vec![root.join("src")];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
for (i, line) in text.lines().enumerate() {
if line.contains(&format!("mermaid{}rs{}renderer", '_', '_')) {
offenders.push(format!("{}:{}", path.display(), i + 1));
}
}
}
}
assert!(
offenders.is_empty(),
"these still reach for the removed renderer: {offenders:?}"
);
}
#[test]
fn venn_beta_is_refused_rather_than_drawn_as_three_boxes() {
assert_eq!(
mermaid_to_svg_reason("venn-beta\n sets [A]\n sets [B]\n", "dark"),
Err(
"not a mermaid diagram konoma can draw: the source starts with `venn-beta`"
.to_string()
)
);
assert!(mermaid_to_svg("venn-beta\n sets [A]\n", "dark").is_none());
for code in [
"cynefin-beta\n domain a",
"wardley-beta\n component a",
"ishikawa\n effect e",
"railroad-beta\n a ::= b",
"treeView\n a",
] {
assert!(
mermaid_to_svg(code, "dark").is_none(),
"a kind konoma has no renderer for must degrade, not be guessed at: {code:?}"
);
}
}
#[test]
fn the_mermaid_renderer_measures_with_the_one_shared_font_database() {
crate::preview::svg::warm_fontdb();
let primed = crate::preview::svg::shared_fontdb();
let measured = crate::preview::svg::shared_fontdb();
assert!(
std::sync::Arc::ptr_eq(&primed, &measured),
"two font databases, which is the double enumeration §8 records"
);
assert!(crate::preview::mermaid::text_metrics::fonts_available());
}
#[test]
fn the_warm_up_reaches_every_shape_of_pipeline() {
let drawn_by_konoma = |code: &str| -> Option<bool> {
let svg = mermaid_to_svg(code, "dark")?;
Some(svg.contains("font-family=\"sans-serif\"") && !svg.contains("Inter"))
};
let warmed = [
("layered graph", "graph LR\nA-->B"),
("sequence", "sequenceDiagram\n A->>B: hi"),
("data chart", "pie\n \"a\" : 1"),
(
"banded chart",
"gantt\n title G\n section S\n t :a1, 2024-01-01, 3d",
),
];
let mut shapes: std::collections::HashSet<&str> = std::collections::HashSet::new();
for (shape, code) in warmed {
match drawn_by_konoma(code) {
Some(true) => {
shapes.insert(shape);
}
Some(false) => panic!("warm-up source is not konoma's: {code:?}"),
None => panic!("warm-up source no longer renders: {code:?}"),
}
}
assert_eq!(
shapes.len(),
4,
"the warm-up no longer touches all four shapes of pipeline: {shapes:?}"
);
warm_mermaid();
}
#[test]
fn a_trailing_newline_does_not_change_the_drawing() {
let a = mermaid_to_svg("graph LR\nA-->B", "dark").unwrap();
let b = mermaid_to_svg("graph LR\nA-->B\n", "dark").unwrap();
let c = mermaid_to_svg("graph LR\nA-->B\n\n\n", "dark").unwrap();
assert_eq!(a, b);
assert_eq!(a, c);
}
#[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 compute_or_fallback_returns_fs_value_on_success_and_fallbacks_value_on_panic() {
assert_eq!(
compute_or_fallback(|| 42, || 0),
42,
"成功時は f の値そのまま"
);
let v = compute_or_fallback(|| -> i32 { panic!("simulated worker panic") }, || 7);
assert_eq!(
v, 7,
"パニック時は fallback の値(=何も送らないよりは安全な既知の失敗状態)"
);
PANIC_SILENCED.with(|c| assert!(!c.get(), "パニック経路でも抑制フラグが残らない"));
}
#[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_eq!(
collect_mermaid_fences(unterminated),
vec!["graph LR\nA-->B\n".to_string()],
);
}
#[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, _extras) = 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, _extras) = 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, _extras) = 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 render_alert_makes_a_colored_callout_not_literal_marker() {
let md = "> [!WARNING]\n> careful with [docs](./x.md)\n";
let on = render_via_dispatcher(
&doc_run(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_via_dispatcher(
&doc_run(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 html_cell_to_markdown_converts_the_inline_tags_it_owns() {
assert_eq!(html_cell_to_markdown("<b>x</b>"), "**x**");
assert_eq!(html_cell_to_markdown("<STRONG>x</STRONG>"), "**x**");
assert_eq!(html_cell_to_markdown("<i>x</i>"), "*x*");
assert_eq!(html_cell_to_markdown("<em>x</em>"), "*x*");
assert_eq!(html_cell_to_markdown("<code>x</code>"), "`x`");
assert_eq!(
html_cell_to_markdown("<a href=\"https://e.com\">site</a>"),
"[site](https://e.com)"
);
assert_eq!(
html_cell_to_markdown("<img src=\"a.png\" alt=\"pic\">"),
""
);
assert_eq!(
html_cell_to_markdown("<img src='a.png' alt='pic'/>"),
""
);
}
#[test]
fn html_cell_to_markdown_nests_a_link_wrapped_image() {
assert_eq!(
html_cell_to_markdown(
"<a href=\"https://e.com\"><img src=\"a.png\" alt=\"badge\"></a>"
),
"[](https://e.com)"
);
}
#[test]
fn html_cell_to_markdown_collapses_breaks_and_newlines_to_spaces() {
assert_eq!(html_cell_to_markdown("a<br>b"), "a b");
assert_eq!(html_cell_to_markdown("a<br />b"), "a b");
assert_eq!(html_cell_to_markdown("\nfirst\nsecond\n"), "first second");
assert_eq!(html_cell_to_markdown("a \nb"), "a b");
}
#[test]
fn html_cell_to_markdown_leaves_every_other_tag_to_the_one_stripper() {
assert_eq!(html_cell_to_markdown("<span class=\"x\">t</span>"), "t");
assert_eq!(html_cell_to_markdown("<a name=\"x\">anchor</a>"), "anchor");
assert_eq!(html_cell_to_markdown("a<!-- gone -->b"), "ab");
assert_eq!(html_cell_to_markdown("a & b <c>"), "a & b <c>");
}
#[test]
fn html_cell_to_markdown_drops_a_dangling_open_anchor_bracket() {
assert_eq!(
html_cell_to_markdown("<a href=\"https://e.com\">site"),
"site"
);
assert_eq!(html_cell_to_markdown("site</a>"), "site");
assert_eq!(
html_cell_to_markdown("<a href=\"https://e.com\">one</a><a href=\"https://f.com\">two"),
"[one](https://e.com)two"
);
}
#[test]
fn html_cell_to_markdown_survives_unterminated_and_multibyte_input() {
assert_eq!(html_cell_to_markdown("a <b unterminated"), "a");
assert_eq!(
html_cell_to_markdown("日本語<b>強調</b>です"),
"日本語**強調**です"
);
assert_eq!(html_cell_to_markdown(""), "");
assert_eq!(html_cell_to_markdown(" "), "");
}
#[test]
fn html_comment_end_covers_the_whole_comment_and_nothing_else() {
assert_eq!(html_comment_end("<!-- x -->tail", 0), Some(10));
let src = "<!-- <tr><td>x</td></tr> -->tail";
assert_eq!(html_comment_end(src, 0).map(|e| &src[e..]), Some("tail"));
assert_eq!(html_comment_end("<td>x</td>", 0), None);
assert_eq!(html_comment_end("<!DOCTYPE html>", 0), None);
assert_eq!(html_comment_end("<!-", 0), None);
assert_eq!(html_comment_end("a <!-- x -->", 0), None);
assert_eq!(html_comment_end("a <!-- x -->", 2), Some(12));
let src = "<!-- a <!-- b -->live";
assert_eq!(html_comment_end(src, 0).map(|e| &src[e..]), Some("live"));
assert_eq!(html_comment_end("<!-- x", 0), Some(6));
assert_eq!(html_comment_end("<!-- <tr><td>x", 0), Some(14));
assert_eq!(html_comment_end("<!-->x", 0), Some(6));
assert_eq!(html_comment_end("<!--->x", 0), Some(7));
let src = "日本<!-- 秘密 -->後";
let at = src.find("<!--").unwrap();
assert_eq!(html_comment_end(src, at).map(|e| &src[e..]), Some("後"));
assert_eq!(html_comment_end(src, 1), None);
}
#[test]
fn the_text_scanners_drop_a_comment_and_everything_in_it() {
let drawn = |raw: &str| {
render_html_block(raw)
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.to_string())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("|")
};
assert_eq!(drawn("a<!-- gone -->b"), "ab|");
assert_eq!(drawn("a<!-- <b>gone</b> -->b"), "ab|");
assert_eq!(drawn("a<!-- <table><tr><td>gone"), "a|");
assert_eq!(drawn("a<!-- x --><!-- y -->b"), "ab|");
assert_eq!(html_cell_to_markdown("a<!-- <b>gone</b> -->b"), "ab");
assert_eq!(html_cell_to_markdown("a<!-- gone"), "a");
assert_eq!(
html_cell_to_markdown("a<!-- <a href=\"u\">gone</a> -->b"),
"ab",
"no `[` may survive from a link the comment swallowed"
);
}
#[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"));
}
fn br_pre_and_render(src: &str) -> (String, Vec<Line<'static>>) {
let pre = process_inline_html(src);
set_details_open(collect_details_open(&pre));
let (lines, _, _extras) = render_markdown_with_images(
&pre,
100,
NO_CODE,
"TwoDark",
false,
&[' ', 'x'],
&|_: &str, _: Option<u16>| ImageSlot::Unavailable,
&|_: &str| MermaidSlot::Text,
"mermaid",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
(pre, lines)
}
fn br_texts(src: &str) -> (Vec<String>, usize) {
let (_, lines) = br_pre_and_render(src);
let headers = lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|s| is_code_header_span(s))
.count();
let texts = lines
.iter()
.map(|l| l.spans.iter().map(|sp| sp.content.as_ref()).collect())
.collect();
(texts, headers)
}
fn banner_render(src: &str) -> (Vec<String>, usize, usize) {
let pre = process_inline_html(src);
set_details_open(collect_details_open(&pre));
let (lines, places, _extras) = render_markdown_with_images(
&pre,
70,
NO_CODE,
"TwoDark",
false,
&[' ', 'x'],
&|_: &str, _: Option<u16>| ImageSlot::Inline { cols: 4, rows: 2 },
&|_: &str| MermaidSlot::Text,
"mermaid",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
let headers = lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|s| is_code_header_span(s))
.count();
let texts = lines
.iter()
.map(|l| l.spans.iter().map(|sp| sp.content.as_ref()).collect())
.collect();
(texts, headers, places.len())
}
#[test]
fn banner_nested_in_a_container_keeps_both_badges_and_draws_no_code_block() {
let single = concat!(
"<p align=\"center\">\n",
" <a href=\"u\"><img src=\"https://s.svg\" alt=\"BADGE-A\"></a>\n",
" <br>\n",
" <a href=\"v\"><img src=\"https://t.svg\" alt=\"BADGE-B\"></a>\n",
"</p>\n",
);
let multi = concat!(
"<p align=\"center\">\n",
" <a href=\"u\">\n",
" <img src=\"https://s.svg\" alt=\"BADGE-A\">\n",
" </a>\n",
" <br>\n",
" <a href=\"v\">\n",
" <img src=\"https://t.svg\" alt=\"BADGE-B\">\n",
" </a>\n",
"</p>\n",
);
let quoted = |b: &str| {
b.lines()
.map(|l| format!("> {l}\n"))
.collect::<Vec<_>>()
.concat()
};
let folded =
|b: &str| format!("<details open>\n<summary>Badges</summary>\n\n{b}\n</details>\n");
for (name, src) in [
("details/single-line", folded(single)),
("details/multi-line", folded(multi)),
("quote/single-line", quoted(single)),
("quote/multi-line", quoted(multi)),
] {
let (texts, headers, images) = banner_render(&src);
assert_eq!(
headers, 0,
"{name}: 入れ子のバナーが字下げコードブロックとして描かれている: {texts:#?}"
);
assert_eq!(
images, 2,
"{name}: バッジが失われている(片方だけ抽出された): {texts:#?}"
);
assert!(
!texts.iter().any(|t| t.contains("</a>")),
"{name}: 生の HTML タグが画面に漏れている: {texts:#?}"
);
}
}
#[test]
fn banner_quoted_multiline_no_longer_leaks_its_leftover_fragments() {
let src = concat!(
"> <p align=\"center\">\n",
"> <a href=\"u\">\n",
"> <img src=\"https://s.svg\" alt=\"BADGE-A\">\n",
"> </a>\n",
"> <br>\n",
"> <a href=\"v\">\n",
"> <img src=\"https://t.svg\" alt=\"BADGE-B\">\n",
"> </a>\n",
"> </p>\n",
);
let (texts, headers, images) = banner_render(src);
assert_eq!((headers, images), (0, 2), "画像は両方とも残る: {texts:#?}");
assert!(
!texts.iter().any(|t| t.contains("</a>")),
"生の HTML タグが画面に漏れている: {texts:#?}"
);
}
#[test]
fn a_lone_br_between_paragraphs_still_breaks_inside_a_container() {
for (name, src) in [
(
"details/no blank line",
"<details open>\nalpha\n<br>\nbeta\n</details>\n",
),
(
"details/blank-separated",
"<details open>\n<summary>S</summary>\n\nalpha\n\n<br>\n\nbeta\n\n</details>\n",
),
("blockquote", "> alpha\n>\n> <br>\n>\n> beta\n"),
] {
let pre = process_inline_html(src);
assert!(
!pre.contains("<br>"),
"{name}: <br> が書き換えられていない: {pre:?}"
);
assert_eq!(
pre.lines().count(),
src.lines().count(),
"{name}: <br> の行ごと落ちて段落の切れ目が消えている: {pre:?}"
);
let (texts, _, _) = banner_render(src);
for want in ["alpha", "beta"] {
assert!(
texts.iter().any(|t| t.contains(want)),
"{name}: {want:?} が失われている: {texts:#?}"
);
}
}
}
#[test]
fn a_br_directly_under_an_unseparated_summary_is_dropped_as_block_body() {
let src = "<details open>\n<summary>S</summary>\nalpha\n<br>\nbeta\n</details>\n";
let pre = process_inline_html(src);
assert_eq!(
pre, "<details open>\n<summary>S</summary>\nalpha\nbeta\n</details>\n",
"想定どおりに <br> 行だけが落ちる: {pre:?}"
);
}
#[test]
fn br_in_an_alert_keeps_the_callout_and_its_fence_whole() {
for (name, src) in [
(
"mid-line",
"> [!NOTE]\n> line one<br>line two\n>\n> ```rust\n> fn a(){}\n> ```\n",
),
(
"trailing (the more common spelling)",
"> [!NOTE]\n> line one<br>\n> line two\n>\n> ```rust\n> fn a(){}\n> ```\n",
),
] {
let (texts, headers) = br_texts(src);
for t in &texts {
assert!(
t.starts_with('▌'),
"{name}: この行が callout の外に逃げている: {t:?}\n全行: {texts:#?}"
);
}
assert!(
texts.iter().any(|t| t.contains("line one"))
&& texts.iter().any(|t| t.contains("line two")),
"{name}: 本文が両方とも残っていない: {texts:#?}"
);
assert_eq!(
headers, 1,
"{name}: alert 内の fence がコードブロックとして描かれていない: {texts:#?}"
);
assert!(
!texts.iter().any(|t| t.contains("> ")),
"{name}: 生の `> ` マーカーが漏れている: {texts:#?}"
);
let blanks = texts.iter().filter(|t| t.trim() == "▌").count();
assert_eq!(
blanks, 1,
"{name}: callout 本文の空行数が想定外(<br> が段落区切りになっている): {texts:#?}"
);
}
}
#[test]
fn br_in_a_table_cell_keeps_one_row() {
let (texts, _) = br_texts("| a | b |\n| --- | --- |\n| x<br>y | z |\n");
let data: Vec<&String> = texts
.iter()
.filter(|t| t.starts_with('│') && !t.contains(" a ") && !t.contains('─'))
.collect();
assert_eq!(data.len(), 1, "データ行がちょうど1行であるべき: {texts:#?}");
assert!(
data[0].contains('x') && data[0].contains('y') && data[0].contains('z'),
"セルの両方の語と隣のセルが1行に残っていない: {:?}",
data[0]
);
}
#[test]
fn bare_br_line_in_an_html_block_does_not_end_it() {
let src = "<p align=\"center\">\n <a href=\"https://ci\">\n <img src=\"https://ci.svg\" alt=\"ci - ios\">\n </a>\n <br>\n <a href=\"LICENSE-MIT\">\n <img src=\"https://mit.svg\" alt=\"License - MIT\">\n </a>\n</p>\n";
let (pre, _) = br_pre_and_render(src);
assert!(
!pre.contains("<br>"),
"HTML ブロック内の裸の <br> は行ごと落とすべき(タグを残すと後段の再配置でブロックを開く): {pre:?}"
);
assert!(
!pre.lines().any(|l| l.trim().is_empty()),
"空白だけの行が残っている(CommonMark では空行=ブロックの終端): {pre:?}"
);
assert_eq!(
pre.lines().count(),
src.lines().count() - 1,
"落ちた行はちょうど <br> の1行であるべき: {pre:?}"
);
let (texts, headers) = br_texts(src);
assert_eq!(
headers, 0,
"バナーが字下げコードブロックとして描かれている: {texts:#?}"
);
assert!(
texts.iter().any(|t| t.contains("License - MIT")),
"<br> の後ろのバッジが失われている: {texts:#?}"
);
}
#[test]
fn br_inside_an_html_block_still_breaks_when_no_blank_line_results() {
let pre = process_inline_html("<div align=\"center\">\n before<br>after\n</div>\n");
assert!(
pre.contains(" before \nafter"),
"行中の <br> はブロック内でも改行に変換されるべき: {pre:?}"
);
assert!(
!pre.contains("<br>"),
"行中の <br> がそのまま残っている: {pre:?}"
);
let pre = process_inline_html("<div align=\"center\">\n one<br>\n two\n</div>\n");
assert!(
pre.contains(" one \n two"),
"行末 <br> が空行を作らずに改行になっていない: {pre:?}"
);
let pre = process_inline_html("<div align=\"center\">\n a<br><br>b\n</div>\n");
assert!(
pre.contains(" a \nb"),
"空行を生む形は空行だけを落として両半分を残すべき: {pre:?}"
);
assert!(
!pre.contains("<br>"),
"空行を生む形でタグが残っている(後段の再配置でブロックを開く): {pre:?}"
);
assert!(
!pre.lines().any(|l| l.trim().is_empty()),
"空白だけの行が残っている: {pre:?}"
);
}
#[test]
fn a_kept_br_in_a_relocated_footnote_would_swallow_the_footnotes_after_it() {
let src = concat!(
"Body with a note[^one], another[^two] and a third[^three].\n",
"\n",
"[^one]: this can lead to tough choices\n",
" <br>\n",
" <br>\n",
" we don't have the luxury of those choices\n",
"\n",
"[^two]: mentions `some_code()` in a span\n",
"\n",
"[^three]: and `another_span` too\n",
);
let (body, _) = process_footnotes_traced(src, &identity_origin(src));
let (pre, lines) = br_pre_and_render(&body);
assert!(
!pre.contains("<br>"),
"再配置された脚注に <br> が生き残っている(後続の脚注ごとブロックに飲まれる): {pre:?}"
);
let texts: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|sp| sp.content.as_ref()).collect())
.collect();
assert!(
!texts.iter().any(|t| t.contains('`')),
"後続の脚注のインラインコードが生のバッククォートで描かれている: {texts:#?}"
);
let code: Vec<&str> = lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|s| is_inline_code_span(s))
.map(|s| s.content.as_ref())
.collect();
for want in ["some_code()", "another_span"] {
assert!(
code.iter().any(|c| c.contains(want)),
"{want} がコードスパンとして描かれていない: code={code:#?}\n{texts:#?}"
);
}
assert!(
texts.iter().any(|t| t.contains("tough choices"))
&& texts.iter().any(|t| t.contains("luxury")),
"脚注本文が失われている: {texts:#?}"
);
}
#[test]
fn br_in_quote_list_and_details_still_renders_as_before() {
let (texts, _) = br_texts("> line one<br>line two\n\ntail\n");
assert!(
texts.iter().any(|t| t.contains("line one"))
&& texts.iter().any(|t| t.contains("line two")),
"引用の両行が残っていない: {texts:#?}"
);
let pre = process_inline_html("> > deep one<br>deep two\n");
assert!(
pre.contains("> > deep one \n> > deep two"),
"入れ子引用のマーカーが継承されていない: {pre:?}"
);
for (name, src) in [
("list item", "- item one<br>item two\n- second\n"),
("nested list item", "- outer\n - inner<br>tail\n"),
] {
let (texts, _) = br_texts(src);
assert!(
!texts.iter().any(|t| t.trim() == "-"),
"{name}: リストマーカーが本文から切り離されている: {texts:#?}"
);
}
let (texts, _) = br_texts(
"<details open>\n<summary>S</summary>\n\nline one<br>line two\n\n</details>\n",
);
assert!(
texts.iter().any(|t| t.contains("line one"))
&& texts.iter().any(|t| t.contains("line two")),
"details 本文が失われている: {texts:#?}"
);
}
#[test]
fn br_stays_literal_in_code_spans_and_both_kinds_of_code_block() {
for (name, src) in [
("code span", "text `a<br>b` more\n"),
("fenced block", "```\na<br>b\n```\n"),
("indented block", "para\n\n a<br>b\n"),
] {
let pre = process_inline_html(src);
assert_eq!(pre, src, "{name}: コード内の <br> が書き換えられている");
}
}
#[test]
fn raw_window_metal_banner_shape_draws_badges_not_a_code_block() {
let src = concat!(
"\n",
"<h1 align=\"center\">rwm</h1>\n",
"<p align=\"center\">\n",
" <a href=\"https://crates.io/crates/rwm\">\n",
" <img src=\"https://img.example/v.svg\" alt=\"crates.io\">\n",
" </a>\n",
" <br>\n",
" <a href=\"LICENSE-MIT\">\n",
" <img src=\"https://img.example/mit.svg\" alt=\"License - MIT\">\n",
" </a>\n",
"</p>\n",
"\ntail\n",
);
let (pre, _) = br_pre_and_render(src);
assert!(
!pre.contains("<br>") && !pre.lines().any(|l| l.trim().is_empty() && !l.is_empty()),
"バナー中の裸の <br> は行ごと落ちるべき(空白だけの行はブロックを終端させる): {pre:?}"
);
let (texts, headers) = br_texts(src);
assert_eq!(
headers, 0,
"バナーが字下げコードブロックとして描かれている: {texts:#?}"
);
for alt in ["crates.io", "License - MIT"] {
assert!(
texts.iter().any(|t| t.contains(alt)),
"バッジ {alt:?} が失われている: {texts:#?}"
);
}
assert!(
!texts.iter().any(|t| t.contains("</a>")),
"生の HTML タグが画面に漏れている: {texts:#?}"
);
}
#[test]
fn static_assertions_banner_shape_draws_badges_not_a_code_block() {
let src = concat!(
"[](https://example.com/repo)\n",
"\n",
"<div align=\"center\">\n",
" <a href=\"https://crates.io/crates/sa\">\n",
" <img src=\"https://img.example/v.svg\" alt=\"Crates.io\">\n",
" <img src=\"https://img.example/d.svg\" alt=\"Downloads\">\n",
" </a>\n",
" <img src=\"https://img.example/rustc.svg\" alt=\"rustc\">\n",
" <br>\n",
" <a href=\"https://example.com/patron\">\n",
" <img src=\"https://img.example/patron.png\" alt=\"Patron\">\n",
" </a>\n",
"</div>\n",
"\ntail.\n",
);
let (texts, headers) = br_texts(src);
assert_eq!(
headers, 0,
"バナーが字下げコードブロックとして描かれている: {texts:#?}"
);
for alt in ["Banner", "Crates.io", "Downloads", "rustc", "Patron"] {
assert!(
texts.iter().any(|t| t.contains(alt)),
"画像 {alt:?} が失われている: {texts:#?}"
);
}
assert!(
texts.iter().any(|t| t.contains("tail.")),
"バナーの後ろの本文が飲み込まれている: {texts:#?}"
);
}
#[test]
fn tinytemplate_banner_shape_draws_links_not_a_code_block() {
let src = concat!(
"<h1 align=\"center\">TT</h1>\r\n",
"\r\n",
"<div align=\"center\">\r\n",
" <a href=\"https://docs.rs/tt/\">API Documentation</a>\r\n",
" |\r\n",
" <a href=\"https://example.com/changelog\">Changelog</a>\r\n",
"</div>\r\n",
"\r\n",
"<div align=\"center\">\r\n",
" <a href=\"https://example.com/actions\">\r\n",
" <img src=\"https://img.example/ci.svg\" alt=\"CI\">\r\n",
" </a>\r\n",
" <a href=\"https://crates.io/crates/tt\">\r\n",
" <img src=\"https://img.example/v.svg\" alt=\"Crates.io\">\r\n",
" </a>\r\n",
"</div>\r\n",
"\r\ntail\r\n",
);
let (texts, headers) = br_texts(src);
assert_eq!(
headers, 0,
"バナーが字下げコードブロックとして描かれている: {texts:#?}"
);
for want in ["API Documentation", "Changelog", "CI", "Crates.io"] {
assert!(
texts.iter().any(|t| t.contains(want)),
"{want:?} が失われている: {texts:#?}"
);
}
}
#[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 process_inline_html_leaves_indented_code_untouched() {
let src = "before\n\n <kbd>x</kbd> stays literal in the transcript\n\nafter <kbd>y</kbd> converts\n";
let out = process_inline_html(src);
assert!(
out.contains(" <kbd>x</kbd> stays literal in the transcript"),
"tag inside an indented code block stays literal, whole line unchanged: {out:?}"
);
assert!(
out.contains("after `y` converts"),
"an unindented tag right after the block still converts: {out:?}"
);
}
#[test]
fn process_inline_html_leaves_a_details_fence_without_a_blank_line_untouched() {
let src = "<details>\n<summary>s</summary>\n```rust\n[^1] <kbd>K</kbd> $x$\n```\n</details>\n\noutside [^1] and <kbd>K</kbd> and $x$\n\n[^1]: def\n";
let out = process_inline_html(src);
assert!(
out.contains("```rust\n[^1] <kbd>K</kbd> $x$\n```"),
"the tag inside the fence right after <summary>, no blank line between them, stays \
literal: {out:?}"
);
assert!(
out.contains("outside [^1] and `K` and $x$"),
"the identical tag outside the details block still converts: {out:?}"
);
}
#[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_leaves_indented_code_untouched() {
let src = "real[^1] reference\n\n\
Example syntax:\n\n write text[^1] like this\n\n[^1]: the definition\n";
let out = process_footnotes(src);
assert!(
out.contains("real¹ reference"),
"the real ref is numbered: {out:?}"
);
assert!(
out.contains(" write text[^1] like this"),
"the indented example ref stays literal, whole line unchanged: {out:?}"
);
}
#[test]
fn process_footnotes_leaves_a_details_fence_without_a_blank_line_untouched() {
let src = "<details>\n<summary>s</summary>\n```rust\n[^1] <kbd>K</kbd> $x$\n```\n</details>\n\noutside [^1] and <kbd>K</kbd> and $x$\n\n[^1]: def\n";
let out = process_footnotes(src);
assert!(
out.contains("```rust\n[^1] <kbd>K</kbd> $x$\n```"),
"the ref inside the fence right after <summary>, no blank line between them, stays \
literal: {out:?}"
);
assert!(
out.contains("outside ¹ and <kbd>K</kbd> and $x$"),
"the identical ref outside the details block still numbers: {out:?}"
);
}
#[test]
fn process_footnotes_no_definitions_is_noop() {
let src = "just [^1] with no definition\n";
assert_eq!(process_footnotes(src), src);
}
#[test]
fn footnote_def_text_panics_on_ascii_then_ideographic_indent() {
let src = "ref[^a]\n\n[^a]: first line\n second line\n\u{3000}third line\n";
let out = process_footnotes(src);
assert!(out.contains("third line"), "out = {out:?}");
}
#[test]
fn footnote_def_text_panics_on_ideographic_then_ascii_indent() {
let src = "ref[^a]\n\n[^a]: first line\n\u{3000}second line\n third line\n";
let out = process_footnotes(src);
assert!(out.contains("third line"), "out = {out:?}");
}
#[test]
fn footnote_def_text_panics_on_ascii_then_nbsp_indent() {
let src = "ref[^a]\n\n[^a]: first line\n second line\n\u{a0}third line\n";
let out = process_footnotes(src);
assert!(out.contains("third line"), "out = {out:?}");
}
#[test]
fn footnote_def_text_panics_even_when_the_definition_has_no_reference() {
let src = "[^a]: first line\n second line\n\u{3000}third line\n";
let out = process_footnotes(src);
assert!(out.contains("third line"), "out = {out:?}");
}
#[test]
fn footnote_def_text_panics_even_past_a_trim_blank_continuation_line() {
let src = "ref[^a]\n\n[^a]: first line\n second line\n\u{3000}\n\u{3000}fourth line\n";
let out = process_footnotes(src);
assert!(out.contains("fourth line"), "out = {out:?}");
}
#[test]
fn footnote_def_text_non_regression_all_ascii_mixed_widths() {
let src = "ref[^a]\n\n[^a]: first line\n second line\n third line\n";
assert_eq!(
process_footnotes(src),
"ref¹\n\n\n---\n\n1. first line\n second line\n third line\n"
);
}
#[test]
fn footnote_def_text_non_regression_no_footnote_syntax_at_all() {
let src = "Just some ordinary text with no footnotes at all.\n";
assert_eq!(process_footnotes(src), src);
}
#[test]
fn footnote_def_text_non_regression_tab_indented_continuation() {
let src = "ref[^a]\n\n[^a]: first line\n\tsecond line\n\tthird line\n";
assert_eq!(
process_footnotes(src),
"ref¹\n\n\n---\n\n1. first line\n second line\n third line\n"
);
}
#[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_via_dispatcher(
&doc_run(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_via_dispatcher(
&doc_run(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");
}
}
#[cfg(test)]
pub(crate) mod task_corpus {
pub fn cases() -> Vec<(&'static str, &'static str)> {
vec![
("plain dash", "- [ ] a\n- [x] b\n"),
("bullet star", "* [ ] a\n* [x] b\n"),
("bullet plus", "+ [ ] a\n+ [x] b\n"),
("upper X", "- [X] a\n"),
("mixed bullets", "- [ ] a\n* [ ] b\n+ [x] c\n"),
("nested two levels", "- [ ] a\n - [ ] b\n - [x] c\n"),
("ordered list sibling", "1. plain\n2. also\n\n- [ ] task\n"),
("heading between", "- [ ] a\n\n## H\n\n- [ ] b\n"),
("alert NOTE", "> [!NOTE]\n> - [ ] a\n"),
("alert TIP", "> [!TIP]\n> - [ ] a\n> - [x] b\n"),
("alert IMPORTANT", "> [!IMPORTANT]\n> - [ ] a\n"),
("alert WARNING", "> [!WARNING]\n> - [x] a\n"),
("alert CAUTION", "> [!CAUTION]\n> - [ ] a\n"),
("alert titled", "> [!NOTE] My title\n> - [ ] a\n"),
("alert nested list", "> [!NOTE]\n> - [ ] a\n> - [ ] b\n"),
("alert then plain", "> [!NOTE]\n> - [ ] in\n\n- [ ] out\n"),
("plain then alert", "- [ ] out\n\n> [!NOTE]\n> - [ ] in\n"),
("two alerts", "> [!NOTE]\n> - [ ] a\n\n> [!TIP]\n> - [ ] b\n"),
("plain blockquote", "> - [ ] quoted\n"),
("nested plain blockquote (two levels)", "> > - [ ] quoted twice\n"),
(
"plain blockquote nested inside an alert",
"> [!NOTE]\n> > - [ ] quoted in alert\n",
),
(
"alert nested inside a plain blockquote",
"> > [!NOTE]\n> > - [ ] alert in quote\n",
),
(
"details closed",
"<details>\n<summary>S</summary>\n\n- [ ] hidden\n\n</details>\n",
),
(
"details open",
"<details open>\n<summary>S</summary>\n\n- [ ] shown\n\n</details>\n",
),
(
"details closed then plain",
"<details>\n<summary>S</summary>\n\n- [ ] hidden\n\n</details>\n\n- [ ] after\n",
),
(
"details open then plain",
"<details open>\n<summary>S</summary>\n\n- [ ] shown\n\n</details>\n\n- [ ] after\n",
),
("fence backtick", "```\n- [ ] not a task\n```\n\n- [ ] real\n"),
("fence tilde", "~~~\n- [ ] not a task\n~~~\n\n- [ ] real\n"),
(
"fence with language",
"```rust\n// - [ ] no\n```\n\n- [ ] real\n",
),
(
"table then task",
"| a | b |\n|---|---|\n| 1 | 2 |\n\n- [ ] after table\n",
),
(
"table cell containing an image",
"| badge | meaning |\n|---|---|\n|  | first |\n",
),
(
"table cell containing a link-wrapped image",
"| crate | badges |\n|---|---|\n| ndk | [](https://ci.example) |\n",
),
("html block then task", "<div>hi</div>\n\n- [ ] after html\n"),
("inline code lookalike", "- [ ] real `- [ ] fake`\n"),
("link in task", "- [ ] see [docs](./d.md)\n"),
("cjk", "- [ ] 日本語のタスク\n- [x] 全角 スペース\n"),
("emphasis in task", "- [ ] **bold** and *em*\n"),
("no trailing newline", "- [ ] a\n- [x] b"),
("crlf", "- [ ] a\r\n- [x] b\r\n"),
("front matter", "---\ntitle: t\n---\n\n- [ ] a\n"),
("footnote", "- [ ] a[^1]\n\n[^1]: note\n"),
(
"fence containing a table lookalike",
"```text\n| a | b |\n|---|---|\n| 1 | 2 |\n```\n\n- [ ] real\n",
),
(
"fence containing an html block lookalike",
"```html\n<div class=\"x\">\nhello\n</div>\n```\n\n- [ ] real\n",
),
(
"fence containing an alert lookalike",
"```text\n> [!NOTE]\nlooks like an alert\n```\n\n- [ ] real\n",
),
("empty doc", ""),
("no tasks", "# just text\n\nparagraph\n"),
(
"everything",
"# Doc\n\n- [ ] top\n\n> [!NOTE] Heads up\n> - [ ] in alert\n> - [x] done\n\n\
| a | b |\n|---|---|\n| 1 | 2 |\n\n```\n- [ ] fenced\n```\n\n\
<details>\n<summary>More</summary>\n\n- [ ] collapsed\n\n</details>\n\n- [x] bottom\n",
),
(
"task-lookalike inside a top-level indented code block stays literal",
"para\n\n - [ ] fake\n\n- [ ] real\n",
),
(
"task-lookalike inside an indented block nested in an alert stays literal",
"> [!NOTE]\n> para\n>\n> - [ ] fake\n>\n> - [ ] real\n",
),
(
"task-lookalike inside an indented block nested in an open details stays literal",
"<details open>\n<summary>S</summary>\n\npara\n\n - [ ] fake\n\n- [ ] real\n\n</details>\n",
),
(
"a real task at a list item's own indentation is still a real task",
"- [ ] outer\n\n - [ ] nested at matching indent\n",
),
(
"an indented paragraph inside a list item is not a task even if it starts with a dash",
"- item\n\n - [ ] looks like a task but is just this item's own indentation\n",
),
(
"task-lookalike inside a fence indented 3 columns (still a real fence)",
"para\n\n ```rust\n - [ ] fake\n ```\n\n- [ ] real\n",
),
(
"task-lookalike inside a fence-lookalike top-level indented code block",
"para\n\n ```rust\n - [ ] fake\n ```\n\n- [ ] real\n",
),
(
"task-lookalike inside a tilde fence with a shorter nested tilde lookalike",
"~~~~md\n~~~\n- [ ] fake\n~~~\n~~~~\n\n- [ ] real\n",
),
(
"task-lookalike inside a backtick fence with a shorter nested backtick lookalike (same char)",
"````md\n```\n- [ ] fake\n```\n````\n\n- [ ] real\n",
),
(
"alert nested inside a closed details hides its task",
"<details>\n<summary>S</summary>\n\n> [!NOTE]\n> - [ ] hidden\n\n</details>\n",
),
(
"alert nested inside an open details shows its task",
"<details open>\n<summary>S</summary>\n\n> [!NOTE]\n> - [ ] shown\n\n</details>\n",
),
(
"details nested inside an alert, closed, hides its task",
"> [!NOTE]\n> <details>\n> <summary>S</summary>\n>\n\
> - [ ] hidden\n>\n> </details>\n",
),
(
"details nested inside an alert, open, shows its task",
"> [!NOTE]\n> <details open>\n> <summary>S</summary>\n>\n\
> - [ ] shown\n>\n> </details>\n",
),
(
"a details nested inside an alert nested inside a closed details hides everything",
"<details>\n<summary>Outer</summary>\n\n> [!NOTE]\n> <details open>\n\
> <summary>Inner</summary>\n>\n> - [ ] deeply hidden\n>\n> </details>\n\n\
</details>\n\n- [ ] real\n",
),
(
"a details nested inside an alert nested inside an open details shows everything",
"<details open>\n<summary>Outer</summary>\n\n> [!NOTE]\n> <details open>\n\
> <summary>Inner</summary>\n>\n> - [ ] deeply shown\n>\n> </details>\n\n\
</details>\n\n- [ ] real\n",
),
(
"an alert nested inside a details nested inside an alert, all open, shows the task",
"> [!NOTE]\n> <details open>\n> <summary>Inner</summary>\n>\n\
> > [!TIP]\n> > - [ ] triple-nested\n>\n> </details>\n",
),
(
"task-lookalike in an indented block right after an ATX heading (no blank) stays literal",
"## Usage\n - [ ] fake\n\n- [ ] real\n",
),
(
"task-lookalike in an indented block right after a thematic break (no blank) stays literal",
"---\n - [ ] fake\n\n- [ ] real\n",
),
(
"task-lookalike in an indented block right after a setext heading underline (no blank) stays literal",
"Title\n=====\n - [ ] fake\n\n- [ ] real\n",
),
(
"task-lookalike in an indented block right after a short setext dash underline (no blank) stays literal",
"Title\n--\n - [ ] fake\n\n- [ ] real\n",
),
]
}
}
#[cfg(test)]
pub(crate) mod code_corpus {
pub fn cases() -> Vec<(&'static str, &'static str)> {
vec![
(
"standalone indented block",
"Normal paragraph.\n\n indented code line\n second line\n\nTail.\n",
),
("indented block at doc start", " line one\n line two\n"),
("tab-indented block", "para\n\n\ttabbed line\n"),
(
"not code: lazy continuation right after a paragraph (no blank)",
"para\n not code, just continues the paragraph\n",
),
(
"not code: inside a list item at the item's own content column",
"- item\n\n still item content, not code\n",
),
(
"not code: inside an ordered list item at the item's own content column",
"1. item\n\n still item content, not code\n",
),
(
"not code: 3-space indent is not enough",
"para\n\n three spaces is not enough\n",
),
(
"two chunks glued by a blank separator into one code block",
"para\n\n chunk one\n\n chunk two\n",
),
(
"mixed: indented block then (after a blank) a real fence",
"para\n\n indented\n\n```rust\nfenced\n```\n",
),
(
"mixed: fence then an indented block right after (no blank needed)",
"```\nfenced\n```\n indented after fence\n",
),
(
"indented block nested inside an open alert",
"> [!NOTE]\n> para\n>\n> indented in alert\n",
),
(
"indented block nested inside an open details",
"<details open>\n<summary>S</summary>\n\npara\n\n indented in details\n\n</details>\n",
),
(
"indented block nested inside a closed details is not counted",
"<details>\n<summary>S</summary>\n\npara\n\n hidden\n\n</details>\n\n```\nreal\n```\n",
),
(
"indented block containing a task-lookalike stays literal code",
"para\n\n - [ ] not a real task\n",
),
(
"front matter then an indented block",
"---\ntitle: t\n---\n\npara\n\n indented after front matter\n",
),
(
"crlf indented block",
"para\r\n\r\n indented\r\n second\r\n",
),
("no trailing newline", "para\n\n indented"),
(
"indented block followed by a real list",
"para\n\n indented\n\n- item\n",
),
(
"a plain (non-alert) block quote wrapping a fence is not detected by either side",
"> para\n>\n> code\n",
),
(
"everything",
"# Doc\n\nintro\n\n top level indented\n\n> [!NOTE]\n> body\n>\n> indented in note\n\n\
<details open>\n<summary>More</summary>\n\ndetail para\n\n indented in details\n\n</details>\n\n\
```rust\nfn real_fence() {}\n```\n",
),
(
"fence indented 3 columns is still a fence, not an indented code block",
"para\n\n ```rust\n fn a(){}\n ```\n",
),
(
"a fence-lookalike indented 4 columns is the literal content of an indented code block",
"para\n\n ```rust\n fn a(){}\n ```\n",
),
(
"closing fence line with a language suffix does not close (has to be exactly the fence chars)",
"```rust\nbody\n```js\nmore\n```\n",
),
(
"a shorter nested tilde fence lookalike inside a longer tilde fence doesn't close it",
"~~~~md\n~~~\ninner\n~~~\n~~~~\n",
),
(
"a tilde-fence lookalike nested inside a backtick fence doesn't close it (different fence char)",
"```rust\n~~~\nnot closing\n~~~\n```\n",
),
("plain 4-backtick fence, no nesting", "````rust\nbody\n````\n"),
(
"a shorter nested backtick fence lookalike inside a longer backtick fence doesn't close it (same char)",
"````md\n```\ninner\n```\n````\n",
),
(
"indented block immediately after an ATX heading (no blank line)",
"## Usage\n npm install foo\n",
),
(
"indented block immediately after a thematic break (no blank line)",
"---\n code here\n",
),
(
"indented block immediately after a setext level-1 heading underline (no blank line)",
"Title\n=====\n code here\n",
),
(
"indented block immediately after a short setext dash underline, too short to also be a thematic break (no blank line)",
"Title\n--\n code here\n",
),
(
"heading then an indented block then an unrelated real fence — the fence must not be collaterally refused",
"## Usage\n npm install foo\n\n```bash\nnpm test\n```\n",
),
(
"① fence indented 4 columns inside an ordered item is a real fence (registry: pastey CONTRIBUTING.md)",
"1. Fork it:\n\n ```sh\n git clone x\n ```\n\n2. Done.\n",
),
(
"② 6-column continuation paragraph under ` 4. ` is not code (registry: LICENSE-APACHE.md)",
" 4. Redistribution. You may reproduce and distribute copies of the\n\n (b) You must cause any modified files to carry prominent notices\n",
),
(
"fence at a bullet item's own content column",
"- Step:\n\n ```sh\n echo hi\n ```\n",
),
(
"fence indented 4 columns inside a bullet item is still a fence",
"- Step:\n\n ```sh\n echo hi\n ```\n",
),
(
"fence-lookalike 4 columns past a bullet item's content column is indented code",
"- Step:\n\n ```sh\n echo hi\n ```\n",
),
(
"indented code inside a bullet item (content column + 4)",
"- Step:\n\n code line\n second line\n",
),
(
"tilde fence inside a bullet item",
"- Step:\n\n ~~~sh\n echo hi\n ~~~\n",
),
(
"tight list: fence on the line right after the marker, no blank line",
"- Step:\n ```sh\n echo hi\n ```\n",
),
(
"tight ordered list: fence indented 4 columns right after the marker line",
"1. Do:\n ```sh\n echo hi\n ```\n",
),
(
"fence at an ordered item's own content column",
"1. Step:\n\n ```sh\n echo hi\n ```\n",
),
(
"indented code inside an ordered item (content column + 4)",
"1. Step:\n\n code line\n",
),
(
"a two-digit ordered marker shifts the content column right",
"10. Step:\n\n ```sh\n echo hi\n ```\n",
),
(
"ordered list with a `)` delimiter",
"1) Step:\n\n ```sh\n echo hi\n ```\n",
),
(
"lazy continuation inside a list item is not code",
"- item text\n still the same paragraph\n",
),
(
"fence inside a nested bullet item",
"- outer\n - inner:\n\n ```sh\n echo hi\n ```\n",
),
(
"continuation paragraph at a nested item's content column is not code",
"- outer\n - inner\n\n still inner content\n",
),
(
"indented code inside a nested item (nested content column + 4)",
"- outer\n - inner\n\n code line\n",
),
(
"fence inside a plain block quote draws no header (tui-markdown prefixes every line)",
"> quoted:\n>\n> ```sh\n> echo hi\n> ```\n",
),
(
"fence inside a nested plain block quote (two levels)",
"> > ```sh\n> > echo hi\n> > ```\n",
),
(
"fence inside an alert nested inside a plain block quote",
"> > [!NOTE]\n> > ```sh\n> > echo hi\n> > ```\n",
),
(
"block quote nested inside a list item, wrapping an indented block",
"- item\n\n > quoted:\n >\n > code\n",
),
(
"block quote nested inside a list item, wrapping a fence",
"- item\n\n > ```sh\n > echo hi\n > ```\n",
),
(
"an alert inside a list item",
"- item\n\n > [!NOTE]\n > ```sh\n > echo hi\n > ```\n",
),
(
"a fence in a list item, then a real top-level fence after the list ends",
"1. Step:\n\n ```sh\n echo hi\n ```\n\nAfter the list.\n\n```rust\nfn a(){}\n```\n",
),
(
"a mermaid fence at an ordered item's content column is diverted to a diagram",
"1. Diagram:\n\n ```mermaid\n flowchart TD\n A-->B\n ```\n",
),
(
"a mermaid fence indented 4 columns is left in the text and drawn as ordinary code",
"1. Diagram:\n\n ```mermaid\n flowchart TD\n A-->B\n ```\n",
),
(
"a top-level mermaid fence with no surrounding complication (image placement parity)",
"```mermaid\nA-->B\n```\n",
),
(
"4-column-indented HTML inside a centered banner is an HTML block, not indented code",
"<div align=\"center\">\n <a href=\"https://example.com\">Docs</a>\n</div>\n\npara\n",
),
(
"an HTML block nested inside a blockquote is drawn instead of dropped entirely",
"> <div align=\"center\">\n> HTML-INSIDE-QUOTE\n> </div>\n",
),
(
"an indented line right after a table block, with no blank line between",
"| a | b |\n|---|---|\n code here\n",
),
(
"an indented line after a one-line HTML comment block is indented code",
"<!-- note -->\n code here\n",
),
(
"an indented line after a paragraph ending in an inline tag is indented code",
"para\n<span>x</span>\n\n code here\n",
),
(
"a centered banner whose links wrap images (registry: criterion README.md)",
"<div align=\"center\">\n <a href=\"https://example.com/ci\"><img src=\"https://img.example/badge.svg\" alt=\"CI\"></a>\n |\n <a href=\"https://example.com/crate\"><img src=\"https://img.example/v.svg\" alt=\"Crates.io\"></a>\n</div>\n\npara\n",
),
(
"checkbox in a nested list item written with three spaces after the bullet (registry: zerocopy agent_docs)",
"* **Checklist:**\n * [ ] Can this be done with an existing utility?\n * [x] Done already\n",
),
(
"checkbox with nothing after the closing bracket (registry: line-clipping README.md)",
"- [x]\n- [ ] with text\n",
),
(
"checkbox with four spaces after the bullet",
"- [ ] four spaces is still a task\n",
),
(
"checkbox-lookalike five spaces after the bullet (the item's content starts with code)",
"- [ ] five spaces is not a task marker\n",
),
(
"checkbox in an ordered list item",
"1. [ ] ordered task\n2. [x] done\n",
),
(
"checkbox inside a nested bullet",
"- outer\n - [ ] inner task\n",
),
(
"checkbox inside a plain block quote",
"> - [ ] quoted task\n",
),
(
"checkboxes around a fence indented inside an ordered item",
"- [ ] before\n\n1. Fork it:\n\n ```sh\n git clone x\n ```\n\n- [x] after\n",
),
(
"closing line with trailing text does not close, but the list item ends the block",
"- item\n\n ```rust\n let x = 1;\n ``` ([#1](https://example.com/1))\n\n- next\n\n| a | b |\n|---|---|\n| 1 | 2 |\n",
),
(
"closing line with trailing text inside a nested list item",
"- outer\n - inner\n\n ```rust\n let x = 1;\n ``` (note)\n\n- after\n\n| a | b |\n|---|---|\n| 1 | 2 |\n",
),
(
"closing line with trailing text inside a block quote",
"> ```rust\n> let x = 1;\n> ``` (note)\n\n| a | b |\n|---|---|\n| 1 | 2 |\n",
),
(
"closing line with trailing text inside a list item inside a block quote",
"> - item\n>\n> ```rust\n> let x = 1;\n> ``` (note)\n\n| a | b |\n|---|---|\n| 1 | 2 |\n",
),
(
"closing line with trailing text at top level really does run to EOF",
"```rust\nlet x = 1;\n``` (note)\n\n| a | b |\n|---|---|\n| 1 | 2 |\n",
),
(
"unclosed fence in a list item ends with the item, not the document",
"- item\n\n ```rust\n let x = 1;\n\n- next\n\n| a | b |\n|---|---|\n| 1 | 2 |\n",
),
(
"a longer closing line closes",
"- item\n\n ```rust\n let x = 1;\n ````\n\n- next\n",
),
(
"a tilde closing line does not close a backtick fence",
"- item\n\n ```rust\n let x = 1;\n ~~~\n\n- next\n",
),
(
"a shorter closing line does not close a longer fence",
"- item\n\n ````rust\n let x = 1;\n ```\n\n- next\n",
),
(
"an alert after a fence whose closing line has trailing text",
"- item\n\n ```rust\n let x = 1;\n ``` (note)\n\n- next\n\n> [!NOTE]\n> body\n",
),
(
"a details block after a fence whose closing line has trailing text",
"- item\n\n ```rust\n let x = 1;\n ``` (note)\n\n- next\n\n<details open>\n<summary>S</summary>\n\nbody\n\n</details>\n",
),
(
"a heading and a real fence after a fence whose closing line has trailing text",
"- item\n\n ```rust\n let x = 1;\n ``` (note)\n\n- next\n\n## H\n\n```sh\necho hi\n```\n",
),
(
"a checkbox after a fence whose closing line has trailing text (list ended by a paragraph)",
"- item\n\n ```rust\n let x = 1;\n ``` (note)\n\nText.\n\n- [ ] a\n- [x] b\n",
),
(
"a checkbox after a fence whose closing line has trailing text (tight list)",
"- item\n ```rust\n let x = 1;\n ``` (note)\n- [ ] a\n- [x] b\n",
),
(
"indented block whose content is a GFM table stays literal code",
"para\n\n | a | b |\n |---|---|\n | 1 | 2 |\n",
),
(
"indented block whose content is an alert header stays literal code",
"para\n\n > [!NOTE]\n > body\n",
),
(
"indented block whose content is a bare tag",
"para\n\n <code>\n",
),
(
"indented block whose content is a tag with attributes",
"para\n\n <a href=\"x\">\n",
),
(
"indented block whose content is a closing tag",
"para\n\n </a>\n",
),
(
"indented block whose content is a void tag",
"para\n\n <br>\n",
),
(
"indented block whose content is an HTML comment",
"para\n\n <!-- a comment -->\n",
),
(
"indented block whose content is a <details> tag",
"para\n\n <details>\n",
),
(
"indented block whose content is a <summary> tag",
"para\n\n <summary>S</summary>\n",
),
(
"indented block whose content is a <kbd> pair",
"para\n\n <kbd>Ctrl</kbd>\n",
),
(
"indented block whose content is an autolink",
"para\n\n <https://example.com>\n",
),
(
"indented block whose content is cjk in angle brackets",
"para\n\n <仕様書>\n",
),
(
"indented block whose first line is plain and a later line is tag-shaped",
"para\n\n plain first\n <div>\n",
),
(
"indented block whose content is tag-shaped, followed by a real fence",
"para\n\n <kbd>K</kbd>\n\n```rust\nfn a(){}\n```\n",
),
(
"a real HTML block and a tag-shaped indented block in one document",
"<div align=\"center\">\n <b>hi</b>\n</div>\n\npara\n\n <div>\n",
),
(
"centered banner whose <img> lines are cut out of it stays HTML",
"<p align=\"center\">\n <a href=\"https://example.com/y\">\n <img src=\"https://img.example/a.svg\" alt=\"a\">\n </a>\n <a href=\"https://example.com/x\">\n <img src=\"https://img.example/b.svg\" alt=\"b\">\n </a>\n</p>\n\ntail\n",
),
(
"centered banner with a bare <br> line in it (registry: raw-window-metal README.md)",
"<p align=\"center\">\n <a href=\"https://crates.io/crates/rwm\">\n <img src=\"https://img.example/v.svg\" alt=\"crates.io\">\n </a>\n <br>\n <a href=\"LICENSE-MIT\">\n <img src=\"https://img.example/mit.svg\" alt=\"License - MIT\">\n </a>\n</p>\n\ntail\n",
),
(
"centered banner preceded by a markdown block image (registry: static_assertions README.md)",
"[](https://example.com/repo)\n\n<div align=\"center\">\n <a href=\"https://crates.io/crates/sa\">\n <img src=\"https://img.example/v.svg\" alt=\"Crates.io\">\n </a>\n <img src=\"https://img.example/rustc.svg\" alt=\"rustc\">\n <br>\n <a href=\"https://example.com/patron\">\n <img src=\"https://img.example/patron.png\" alt=\"Patron\">\n </a>\n</div>\n\ntail.\n",
),
(
"crlf centered banner with no <br> (registry: tinytemplate README.md)",
"<h1 align=\"center\">TT</h1>\r\n\r\n<div align=\"center\">\r\n <a href=\"https://docs.rs/tt/\">API Documentation</a>\r\n |\r\n <a href=\"https://example.com/changelog\">Changelog</a>\r\n</div>\r\n\r\n<div align=\"center\">\r\n <a href=\"https://example.com/actions\">\r\n <img src=\"https://img.example/ci.svg\" alt=\"CI\">\r\n </a>\r\n</div>\r\n\r\ntail\r\n",
),
(
"KNOWN FAILURE: list item fence glued to a following inline-code paragraph (no blank line) drops the header",
"1. item:\n ```\n aaa\n ```\n `inline` after\n",
),
(
"KNOWN FAILURE: same glue, with an unrelated real fence following later in the document",
"1. item:\n ```\n aaa\n ```\n `inline` after\n\n```\nbbb\n```\n",
),
(
"control: list item fence + a plain-text lazy continuation (no blank line) does not glue",
"1. item:\n ```\n aaa\n ```\n after\n",
),
(
"control: list item fence + a blank line before the inline-code paragraph does not glue",
"1. item:\n ```\n aaa\n ```\n\n `inline` after\n",
),
(
"KNOWN FAILURE: same glue in a bullet list item",
"- item:\n ```\n aaa\n ```\n `inline` after\n",
),
(
"control: bullet list item fence + a plain-text lazy continuation does not glue",
"- item:\n ```\n aaa\n ```\n after\n",
),
(
"list item fence nested inside a plain block quote, with an inline-code continuation — masked by the pre-existing block-quote non-detection, not the glue defect",
"> - item:\n> ```\n> aaa\n> ```\n> `inline` after\n",
),
(
"list item fence with a two-line body (count parity only — content is checked elsewhere)",
"1. item:\n\n ```\n aaa\n bbb\n ```\n",
),
]
}
}
#[cfg(test)]
pub(crate) mod code_span_corpus {
pub struct Case {
pub name: String,
pub src: String,
pub footnotes: String,
pub inline_html: String,
pub math: Vec<(String, bool)>,
}
const PAYLOAD: &str = "[^1] <kbd>K</kbd> $x$";
const PAYLOAD_FN: &str = "¹ <kbd>K</kbd> $x$";
const PAYLOAD_IH: &str = "[^1] `K` $x$";
fn verbatim(name: &str, block: &str) -> Case {
Case {
name: name.to_string(),
src: format!("{block}\n\nout {PAYLOAD}\n\n[^1]: note\n"),
footnotes: format!("{block}\n\nout {PAYLOAD_FN}\n\n\n---\n\n1. note\n"),
inline_html: format!("{block}\n\nout {PAYLOAD_IH}\n\n[^1]: note\n"),
math: vec![("x".to_string(), false)],
}
}
fn no_span(name: &str, line: &str, line_fn: &str, line_ih: &str) -> Case {
Case {
name: name.to_string(),
src: format!("{line}\n\nout {PAYLOAD}\n\n[^1]: note\n"),
footnotes: format!("{line_fn}\n\nout {PAYLOAD_FN}\n\n\n---\n\n1. note\n"),
inline_html: format!("{line_ih}\n\nout {PAYLOAD_IH}\n\n[^1]: note\n"),
math: vec![("x".to_string(), false), ("x".to_string(), false)],
}
}
fn case(
name: &str,
src: &str,
footnotes: &str,
inline_html: &str,
math: &[(&str, bool)],
) -> Case {
Case {
name: name.to_string(),
src: src.to_string(),
footnotes: footnotes.to_string(),
inline_html: inline_html.to_string(),
math: math.iter().map(|(l, d)| (l.to_string(), *d)).collect(),
}
}
pub fn cases() -> Vec<Case> {
let mut v = vec![
verbatim("one backtick", &format!("a `{PAYLOAD}` b")),
verbatim("two backticks", &format!("a ``{PAYLOAD}`` b")),
verbatim("three backticks", &format!("a ```{PAYLOAD}``` b")),
verbatim(
"a span may contain a shorter backtick run",
&format!("a `` `{PAYLOAD}` `` b"),
),
verbatim(
"a span may contain a lone backtick",
&format!("a ``{PAYLOAD} ` tail`` b"),
),
verbatim("span at line start", &format!("`{PAYLOAD}` tail")),
verbatim("span at line end", &format!("head `{PAYLOAD}`")),
verbatim("span is the whole line", &format!("`{PAYLOAD}`")),
verbatim("two spans on one line", "`[^1]` mid `<kbd>K</kbd>` end"),
verbatim("three spans on one line", "`[^1]`, `<kbd>K</kbd>`, `$x$`"),
verbatim(
"span spanning the line with text either side",
&format!("x`{PAYLOAD}`y"),
),
verbatim("cjk around a span", &format!("日本 `{PAYLOAD}` 語")),
verbatim(
"cjk immediately against the delimiters",
&format!("日本`{PAYLOAD}`語"),
),
verbatim(
"emoji immediately against the delimiters",
&format!("🎉`{PAYLOAD}`🎉"),
),
verbatim("cjk inside the span", &format!("a `日本{PAYLOAD}語` b")),
verbatim(
"escaped multibyte char before a span",
&format!("a \\あ `{PAYLOAD}` b"),
),
verbatim(
"escaped emoji before a span",
&format!("a \\🎉 `{PAYLOAD}` b"),
),
verbatim("backtick fence", &format!("```\n{PAYLOAD}\n```")),
verbatim("tilde fence", &format!("~~~\n{PAYLOAD}\n~~~")),
verbatim("fence with a language", &format!("```rust\n{PAYLOAD}\n```")),
verbatim(
"fence holding an unclosed backtick",
&format!("```\n{PAYLOAD} ` alone\n```"),
),
verbatim(
"span on the line after a fence",
&format!("```\ncode\n```\n\nafter `{PAYLOAD}` end"),
),
no_span(
"opener longer than closer is not a span",
&format!("a ``{PAYLOAD}` b"),
&format!("a ``{PAYLOAD_FN}` b"),
&format!("a ``{PAYLOAD_IH}` b"),
),
no_span(
"closer longer than opener is not a span",
&format!("a `{PAYLOAD}`` b"),
&format!("a `{PAYLOAD_FN}`` b"),
&format!("a `{PAYLOAD_IH}`` b"),
),
no_span(
"unclosed backtick",
&format!("a `{PAYLOAD} b"),
&format!("a `{PAYLOAD_FN} b"),
&format!("a `{PAYLOAD_IH} b"),
),
no_span(
"escaped backtick does not open a span",
&format!("a \\`{PAYLOAD}` b"),
&format!("a \\`{PAYLOAD_FN}` b"),
&format!("a \\`{PAYLOAD_IH}` b"),
),
no_span(
"two adjacent backticks with no closer",
&format!("a `` {PAYLOAD} b"),
&format!("a `` {PAYLOAD_FN} b"),
&format!("a `` {PAYLOAD_IH} b"),
),
no_span(
"no backticks at all (the control for every case above)",
&format!("a {PAYLOAD} b"),
&format!("a {PAYLOAD_FN} b"),
&format!("a {PAYLOAD_IH} b"),
),
];
v.push(verbatim(
"escaped backslash then a real span",
&format!("a \\\\`{PAYLOAD}` b"),
));
v.extend([
case(
"del inside and outside",
"`<del>d</del>` and <del>d</del>\n",
"`<del>d</del>` and <del>d</del>\n",
"`<del>d</del>` and ~~d~~\n",
&[],
),
case(
"s inside and outside",
"`<s>d</s>` and <s>d</s>\n",
"`<s>d</s>` and <s>d</s>\n",
"`<s>d</s>` and ~~d~~\n",
&[],
),
case(
"strike inside and outside",
"`<strike>d</strike>` and <strike>d</strike>\n",
"`<strike>d</strike>` and <strike>d</strike>\n",
"`<strike>d</strike>` and ~~d~~\n",
&[],
),
case(
"sup inside and outside",
"`<sup>2</sup>` and <sup>2</sup>\n",
"`<sup>2</sup>` and <sup>2</sup>\n",
"`<sup>2</sup>` and ²\n",
&[],
),
case(
"sub inside and outside",
"`<sub>2</sub>` and <sub>2</sub>\n",
"`<sub>2</sub>` and <sub>2</sub>\n",
"`<sub>2</sub>` and ₂\n",
&[],
),
case(
"br inside and outside",
"`<br>` and <br> tail\n",
"`<br>` and <br> tail\n",
"`<br>` and \n tail\n",
&[],
),
case(
"br self-closing inside and outside",
"`<br />` and <br /> tail\n",
"`<br />` and <br /> tail\n",
"`<br />` and \n tail\n",
&[],
),
case(
"uppercase br inside and outside",
"`<BR>` and <BR> tail\n",
"`<BR>` and <BR> tail\n",
"`<BR>` and \n tail\n",
&[],
),
case(
"display math inside and outside",
"`$$x$$` and $$y$$\n",
"`$$x$$` and $$y$$\n",
"`$$x$$` and $$y$$\n",
&[("y", true)],
),
case(
"paren math inside and outside",
"`\\(x\\)` and \\(y\\)\n",
"`\\(x\\)` and \\(y\\)\n",
"`\\(x\\)` and \\(y\\)\n",
&[("y", false)],
),
case(
"bracket math inside and outside",
"`\\[x\\]` and \\[y\\]\n",
"`\\[x\\]` and \\[y\\]\n",
"`\\[x\\]` and \\[y\\]\n",
&[("y", true)],
),
case(
"a tag pair straddling a code span (palette README)",
"x <strike>Enables `named::from_str`, which maps names.</strike>\n",
"x <strike>Enables `named::from_str`, which maps names.</strike>\n",
"x ~~Enables `named::from_str`, which maps names.~~\n",
&[],
),
case(
"a tag pair straddling two code spans",
"<del>a `b` c `d` e</del>\n",
"<del>a `b` c `d` e</del>\n",
"~~a `b` c `d` e~~\n",
&[],
),
case(
"a kbd pair straddling a code span",
"<kbd>press `X` now</kbd>\n",
"<kbd>press `X` now</kbd>\n",
"`press `X` now`\n",
&[],
),
case(
"the only reference is inside a span",
"just `[^1]` here\n\n[^1]: note\n",
"just `[^1]` here\n\n[^1]: note\n",
"just `[^1]` here\n\n[^1]: note\n",
&[],
),
case(
"reference inside a span on one line, outside on the next",
"`[^1]`\n\nreal [^1]\n\n[^1]: note\n",
"`[^1]`\n\nreal ¹\n\n\n---\n\n1. note\n",
"`[^1]`\n\nreal [^1]\n\n[^1]: note\n",
&[],
),
case(
"numbering ignores references inside spans",
"`[^b]` then [^a] then [^b]\n\n[^a]: A\n[^b]: B\n",
"`[^b]` then ¹ then ²\n\n\n---\n\n1. A\n2. B\n",
"`[^b]` then [^a] then [^b]\n\n[^a]: A\n[^b]: B\n",
&[],
),
case(
"undefined reference stays literal inside and outside",
"`[^9]` and [^9] and [^1]\n\n[^1]: note\n",
"`[^9]` and [^9] and ¹\n\n\n---\n\n1. note\n",
"`[^9]` and [^9] and [^1]\n\n[^1]: note\n",
&[],
),
case("empty document", "", "", "", &[]),
case(
"span holding only spaces",
"a ` ` b [^1]\n\n[^1]: note\n",
"a ` ` b ¹\n\n\n---\n\n1. note\n",
"a ` ` b [^1]\n\n[^1]: note\n",
&[],
),
case(
"line that is nothing but backticks",
"````\n",
"````\n",
"````\n",
&[],
),
case(
"trailing backslash at end of line",
"a [^1] \\\n\n[^1]: note\n",
"a ¹ \\\n\n\n---\n\n1. note\n",
"a [^1] \\\n\n[^1]: note\n",
&[],
),
]);
let ind3 = " ".repeat(3);
let ind4 = " ".repeat(4);
let ind5 = " ".repeat(5);
let ind6 = " ".repeat(6);
let ind8 = " ".repeat(8);
v.extend([
verbatim("indented code block", &format!(" {PAYLOAD}")),
verbatim(
"tab-indented code block",
&format!("\t{PAYLOAD}"),
),
no_span(
"indented three columns is not a code block (ordinary paragraph)",
&format!("{ind3}{PAYLOAD}"),
&format!("{ind3}{PAYLOAD_FN}"),
&format!("{ind3}{PAYLOAD_IH}"),
),
verbatim(
"indented eight columns is still one literal code block",
&format!("{ind8}{PAYLOAD}"),
),
verbatim(
"indented code block after a paragraph",
&format!("para\n\n{ind4}{PAYLOAD}"),
),
verbatim(
"indented code block right after a heading, no blank line",
&format!("# H\n{ind4}{PAYLOAD}"),
),
verbatim(
"indented code block inside a list item, six columns, blank line before it",
&format!("- item\n\n{ind6}{PAYLOAD}"),
),
no_span(
"indented four columns inside a list item is not a code block (paragraph continuation)",
&format!("- item\n{ind4}{PAYLOAD}"),
&format!("- item\n{ind4}{PAYLOAD_FN}"),
&format!("- item\n{ind4}{PAYLOAD_IH}"),
),
verbatim(
"indented code block inside a blockquote",
&format!(">{ind5}{PAYLOAD}"),
),
verbatim(
"two indented chunks separated by a blank line stay one literal block",
&format!("{ind4}{PAYLOAD}\n\n{ind4}{PAYLOAD}"),
),
case(
"del inside an indented code block and outside",
" <del>d</del>\n\n<del>d</del>\n",
" <del>d</del>\n\n<del>d</del>\n",
" <del>d</del>\n\n~~d~~\n",
&[],
),
case(
"s inside an indented code block and outside",
" <s>d</s>\n\n<s>d</s>\n",
" <s>d</s>\n\n<s>d</s>\n",
" <s>d</s>\n\n~~d~~\n",
&[],
),
case(
"strike inside an indented code block and outside",
" <strike>d</strike>\n\n<strike>d</strike>\n",
" <strike>d</strike>\n\n<strike>d</strike>\n",
" <strike>d</strike>\n\n~~d~~\n",
&[],
),
case(
"sup inside an indented code block and outside",
" <sup>2</sup>\n\n<sup>2</sup>\n",
" <sup>2</sup>\n\n<sup>2</sup>\n",
" <sup>2</sup>\n\n²\n",
&[],
),
case(
"sub inside an indented code block and outside",
" <sub>2</sub>\n\n<sub>2</sub>\n",
" <sub>2</sub>\n\n<sub>2</sub>\n",
" <sub>2</sub>\n\n₂\n",
&[],
),
case(
"br inside an indented code block and outside",
" <br>\n\n<br> tail\n",
" <br>\n\n<br> tail\n",
" <br>\n\n \n tail\n",
&[],
),
case(
"display math ($$...$$) inside an indented code block and outside",
" $$x$$\n\n$$y$$\n",
" $$x$$\n\n$$y$$\n",
" $$x$$\n\n$$y$$\n",
&[("y", true)],
),
case(
"paren math (\\(...\\)) inside an indented code block and outside",
" \\(x\\)\n\n\\(y\\)\n",
" \\(x\\)\n\n\\(y\\)\n",
" \\(x\\)\n\n\\(y\\)\n",
&[("y", false)],
),
case(
"bracket math (\\[...\\]) inside an indented code block and outside",
" \\[x\\]\n\n\\[y\\]\n",
" \\[x\\]\n\n\\[y\\]\n",
" \\[x\\]\n\n\\[y\\]\n",
&[("y", true)],
),
case(
"escaped comma inside display math ($$a\\,b$$)",
"$$a\\,b$$\n",
"$$a\\,b$$\n",
"$$a\\,b$$\n",
&[("a\\,b", true)],
),
case(
"escaped underscore inside display math ($$a\\_b$$)",
"$$a\\_b$$\n",
"$$a\\_b$$\n",
"$$a\\_b$$\n",
&[("a\\_b", true)],
),
case(
"escaped percent inside inline math ($a\\%b$)",
"$a\\%b$\n",
"$a\\%b$\n",
"$a\\%b$\n",
&[("a\\%b", false)],
),
case(
"escaped comma inside backslash-paren inline math (\\(a\\,b\\))",
"\\(a\\,b\\)\n",
"\\(a\\,b\\)\n",
"\\(a\\,b\\)\n",
&[("a\\,b", false)],
),
case(
"escaped comma inside backslash-bracket display math (\\[a\\,b\\])",
"\\[a\\,b\\]\n",
"\\[a\\,b\\]\n",
"\\[a\\,b\\]\n",
&[("a\\,b", true)],
),
case(
"escaped comma at the very start of display math content ($$\\,ab$$)",
"$$\\,ab$$\n",
"$$\\,ab$$\n",
"$$\\,ab$$\n",
&[("\\,ab", true)],
),
case(
"escaped comma right before the closing delimiter of display math ($$ab\\,$$)",
"$$ab\\,$$\n",
"$$ab\\,$$\n",
"$$ab\\,$$\n",
&[("ab\\,", true)],
),
case(
"escaped comma between cjk characters inside display math ($$あ\\,い$$)",
"$$あ\\,い$$\n",
"$$あ\\,い$$\n",
"$$あ\\,い$$\n",
&[("あ\\,い", true)],
),
case(
"escaped math inside an inline code span stays literal; the same shape outside is lifted",
"`$$a\\,b$$` and $$c\\,d$$\n",
"`$$a\\,b$$` and $$c\\,d$$\n",
"`$$a\\,b$$` and $$c\\,d$$\n",
&[("c\\,d", true)],
),
case(
"escaped currency dollars mixed with an unrelated escape elsewhere render literally, not as math",
"cost \\$5 and \\$10 for \\*not italic\\*.\n",
"cost \\$5 and \\$10 for \\*not italic\\*.\n",
"cost \\$5 and \\$10 for \\*not italic\\*.\n",
&[],
),
case(
"a never-closing dollar sign followed by unrelated bold markup does not crash",
"It costs $50 **bold** text.\n",
"It costs $50 **bold** text.\n",
"It costs $50 **bold** text.\n",
&[],
),
case(
"a never-closing dollar sign followed by markup containing a line break does not crash",
"It costs $50 *italic\nbreak* more.\n",
"It costs $50 *italic\nbreak* more.\n",
"It costs $50 *italic\nbreak* more.\n",
&[],
),
case(
"a never-closing dollar sign followed by an unrelated link does not crash",
"It costs $50 [a link](url) more.\n",
"It costs $50 [a link](url) more.\n",
"It costs $50 [a link](url) more.\n",
&[],
),
case(
"escaped math resolves across an intervening inline code span",
"$$a\\,b `x` c\\_d$$\n",
"$$a\\,b `x` c\\_d$$\n",
"$$a\\,b `x` c\\_d$$\n",
&[("a\\,b `x` c\\_d", true)],
),
case(
"escaped math resolves across intervening nested markup",
"$$a\\,b **c** d\\_e$$\n",
"$$a\\,b **c** d\\_e$$\n",
"$$a\\,b **c** d\\_e$$\n",
&[("a\\,b **c** d\\_e", true)],
),
verbatim(
"details/summary immediately followed by a fenced code block, no blank line",
&format!("<details>\n<summary>s</summary>\n```rust\n{PAYLOAD}\n```\n</details>"),
),
]);
v.extend([
verbatim(
"indented code block opening with a rewritable tag",
&format!("{ind4}<kbd>K</kbd>\n{ind4}{PAYLOAD}"),
),
verbatim(
"indented code block opening with a closing tag",
&format!("{ind4}</a>\n{ind4}{PAYLOAD}"),
),
verbatim(
"indented code block opening with an HTML comment",
&format!("{ind4}<!-- c -->\n{ind4}{PAYLOAD}"),
),
verbatim(
"indented code block opening with a tag with attributes",
&format!("{ind4}<a href=\"x\">\n{ind4}{PAYLOAD}"),
),
verbatim(
"indented code block opening with an autolink",
&format!("{ind4}<https://example.com>\n{ind4}{PAYLOAD}"),
),
verbatim(
"indented code block opening with cjk in angle brackets",
&format!("{ind4}<仕様書>\n{ind4}{PAYLOAD}"),
),
case(
"bare br line inside a centered banner is removed, the rest byte-identical",
"<p align=\"center\">\n <a href=\"https://a\">\n <br>\n <a href=\"https://b\">\n</p>\n",
"<p align=\"center\">\n <a href=\"https://a\">\n <br>\n <a href=\"https://b\">\n</p>\n",
"<p align=\"center\">\n <a href=\"https://a\">\n <a href=\"https://b\">\n</p>\n",
&[],
),
case(
"mid-line br inside a centered banner is still a hard break",
"<div align=\"center\">\n before<br>after\n</div>\n",
"<div align=\"center\">\n before<br>after\n</div>\n",
"<div align=\"center\">\n before \nafter\n</div>\n",
&[],
),
]);
v
}
}
#[cfg(test)]
mod code_span_parity_tests {
use super::*;
fn spans_of(src: &str) -> Vec<String> {
let lines: Vec<&str> = src.lines().collect();
let fenced = literal_code_mask(&lines);
let mut out = Vec::new();
for (i, line) in lines.iter().enumerate() {
if fenced[i] {
continue;
}
let mut cursor = 0;
while let Some((s, e)) = next_inline_code_span(line, cursor) {
out.push(line[s..e].to_string());
cursor = e;
}
}
out
}
fn count_of(hay: &str, needle: &str) -> usize {
if needle.is_empty() {
return 0;
}
hay.matches(needle).count()
}
#[test]
fn code_spans_are_literal_in_every_source_pass() {
for c in code_span_corpus::cases() {
assert_eq!(
process_footnotes(&c.src),
c.footnotes,
"process_footnotes disagrees for case {:?} (src {:?})",
c.name,
c.src
);
assert_eq!(
process_inline_html(&c.src),
c.inline_html,
"process_inline_html disagrees for case {:?} (src {:?})",
c.name,
c.src
);
let math: Vec<(String, bool)> = collect_math_exprs(&c.src);
assert_eq!(
math, c.math,
"collect_math_exprs disagrees for case {:?} (src {:?})",
c.name, c.src
);
}
}
#[test]
fn code_span_text_survives_every_string_pass_verbatim() {
for c in code_span_corpus::cases() {
let spans = spans_of(&c.src);
for pass in ["footnotes", "inline_html"] {
let out = match pass {
"footnotes" => process_footnotes(&c.src),
_ => process_inline_html(&c.src),
};
for span in &spans {
let want = count_of(&c.src, span);
let got = count_of(&out, span);
assert!(
got >= want,
"{pass} lost or altered the code span {span:?} in case {:?}: \
appears {want}x in the source but {got}x in {out:?}",
c.name
);
}
}
}
}
#[test]
fn math_extraction_treats_code_span_contents_as_opaque() {
for c in code_span_corpus::cases() {
let lines: Vec<&str> = c.src.lines().collect();
let literal = literal_code_mask(&lines);
let mut blanked = String::new();
for (i, line) in lines.iter().enumerate() {
if literal[i] {
blanked.push_str(line);
blanked.push('\n');
continue;
}
let mut cursor = 0;
while let Some((s, e)) = next_inline_code_span(line, cursor) {
blanked.push_str(&line[cursor..s]);
let run = line[s..e].len() - line[s..e].trim_matches('`').len();
let ticks = "`".repeat(run / 2);
blanked.push_str(&ticks);
blanked.push('x');
blanked.push_str(&ticks);
cursor = e;
}
blanked.push_str(&line[cursor..]);
blanked.push('\n');
}
assert_eq!(
collect_math_exprs(&c.src),
collect_math_exprs(&blanked),
"math extraction changed when only code-span *contents* changed, in case {:?}\n\
src {:?}\n blanked {:?}",
c.name,
c.src,
blanked
);
}
}
#[test]
fn next_inline_code_span_follows_commonmark_backtick_rules() {
type Probe = (&'static str, usize, Option<(usize, usize)>);
let cases: &[Probe] = &[
("`a`", 0, Some((0, 3))),
("x `a` y", 0, Some((2, 5))),
("``a``", 0, Some((0, 5))),
("```a```", 0, Some((0, 7))),
("``a`", 0, None),
("`a``", 0, None),
("```a`", 0, None),
("`` `a` ``", 0, Some((0, 9))),
("`` a `b` c", 0, Some((5, 8))),
("\\`a`", 0, None),
("\\\\`a`", 0, Some((2, 5))),
("a \\あ `b`", 0, Some((7, 10))),
("a \\🎉 `b`", 0, Some((8, 11))),
("日本`a`語", 0, Some((6, 9))),
("🎉`a`", 0, Some((4, 7))),
("`a` `b`", 3, Some((4, 7))),
("`a` `b`", 7, None),
("no backticks here", 0, None),
("```", 0, None),
("", 0, None),
("a \\", 0, None),
];
for (line, from, want) in cases {
assert_eq!(
next_inline_code_span(line, *from),
*want,
"next_inline_code_span({line:?}, {from}) mismatched"
);
if let Some((s, e)) = want {
assert!(
line.is_char_boundary(*s) && line.is_char_boundary(*e),
"{line:?} span ({s},{e}) must land on char boundaries"
);
}
}
}
#[test]
fn documents_without_code_spans_are_byte_identical_to_the_previous_behavior() {
let cases: &[(&str, &str, &str)] = &[
(
"Press <kbd>Ctrl</kbd>. H<sub>2</sub>O. <del>old</del> new.\n",
"Press <kbd>Ctrl</kbd>. H<sub>2</sub>O. <del>old</del> new.\n",
"Press `Ctrl`. H₂O. ~~old~~ new.\n",
),
(
"A claim.[^src] More text.\n\n[^src]: The evidence.\n",
"A claim.¹ More text.\n\n\n---\n\n1. The evidence.\n",
"A claim.[^src] More text.\n\n[^src]: The evidence.\n",
),
(
"one[^a] two[^b] one again[^a]\n\n[^a]: A\n[^b]: B\n",
"one¹ two² one again¹\n\n\n---\n\n1. A\n2. B\n",
"one[^a] two[^b] one again[^a]\n\n[^a]: A\n[^b]: B\n",
),
(
"line one<br>line two\n",
"line one<br>line two\n",
"line one \nline two\n",
),
(
"x<sup>2</sup> and <s>gone</s> and <strike>also</strike>\n",
"x<sup>2</sup> and <s>gone</s> and <strike>also</strike>\n",
"x² and ~~gone~~ and ~~also~~\n",
),
(
"undefined[^nope] stays\n\n[^used]: u\n",
"undefined[^nope] stays\n\n[^used]: u\n",
"undefined[^nope] stays\n\n[^used]: u\n",
),
(
"```\n[^1] <kbd>K</kbd>\n```\n\nafter[^1]\n\n[^1]: n\n",
"```\n[^1] <kbd>K</kbd>\n```\n\nafter¹\n\n\n---\n\n1. n\n",
"```\n[^1] <kbd>K</kbd>\n```\n\nafter[^1]\n\n[^1]: n\n",
),
(
"日本語[^1]の脚注 <kbd>変換</kbd>\n\n[^1]: 注\n",
"日本語¹の脚注 <kbd>変換</kbd>\n\n\n---\n\n1. 注\n",
"日本語[^1]の脚注 `変換`\n\n[^1]: 注\n",
),
("", "", ""),
(
"no markup at all\n",
"no markup at all\n",
"no markup at all\n",
),
];
for (src, want_fn, want_ih) in cases {
assert!(
!src.contains('`') || src.contains("```"),
"this test is only about documents without inline code spans: {src:?}"
);
assert_eq!(&process_footnotes(src), want_fn, "footnotes for {src:?}");
assert_eq!(
&process_inline_html(src),
want_ih,
"inline html for {src:?}"
);
}
}
#[test]
fn math_delimiters_straddling_a_code_span_still_swallow_it_known_limitation() {
assert_eq!(
collect_math_exprs("straddle $a `b` c$ end\n"),
vec![("a `b` c".to_string(), false)],
"if this changed, the straddling limitation was fixed (or made worse) — update the note"
);
assert!(collect_math_exprs("`$a$` only\n").is_empty());
}
#[test]
fn code_span_detection_is_line_scoped_for_every_pass() {
let src = "open `[^1]\nstill [^1]` closed\n\n[^1]: note\n";
assert_eq!(
process_footnotes(src),
"open `¹\nstill ¹` closed\n\n\n---\n\n1. note\n"
);
let html = "open `<kbd>K</kbd>\nstill <kbd>K</kbd>` closed\n";
assert_eq!(process_inline_html(html), "open ``K`\nstill `K`` closed\n");
assert_eq!(
collect_math_exprs("open `$x$\nstill $y$` closed\n").len(),
0,
"the model path's own parser now spans a code span across a newline, matching \
CommonMark — see this test's own doc comment"
);
assert_eq!(
collect_math_exprs_legacy("open `$x$\nstill $y$` closed\n").len(),
2,
"the legacy scanner's own line-scoping is unchanged"
);
}
}
#[cfg(test)]
mod task_scan_parity_tests {
use super::*;
pub(super) fn rendered_tasks(src: &str) -> usize {
set_details_open(Vec::new());
let lines = render_markdown_tasks(
src,
100,
CodeStyle::default(),
"TwoDark",
false,
&[' ', 'x'],
);
lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|s| is_task_span(s))
.count()
}
pub(super) const PLAIN_QUOTE_TASK_SCANNER_GAP: &[&str] = &[
"plain blockquote",
"nested plain blockquote (two levels)",
"plain blockquote nested inside an alert",
"alert nested inside a plain blockquote",
"checkbox inside a plain block quote",
];
#[test]
fn scanner_counts_exactly_what_the_renderer_draws() {
for (name, src) in task_corpus::cases() {
set_details_open(Vec::new());
let drawn = rendered_tasks(src);
let scanned = task_source_locs(src, &[' ', 'x'], &[]).len();
if PLAIN_QUOTE_TASK_SCANNER_GAP.contains(&name) {
assert_eq!(
(drawn, scanned),
(1, 0),
"{name}: 既知のプレーン引用チェックボックス差異の形が変わった\
(PLAIN_QUOTE_TASK_SCANNER_GAP のコメント参照)\n--- src ---\n{src}"
);
continue;
}
assert_eq!(
drawn, scanned,
"{name}: 画面のチェックボックス数と書き戻しスキャナの数が食い違う\
(この文書ではトグルが全部中止される)\n--- src ---\n{src}"
);
}
}
fn rendered_code_blocks(src: &str) -> usize {
set_details_open(Vec::new());
let (lines, _, _extras) = render_markdown_with_images(
src,
100,
CodeStyle::default(),
"TwoDark",
false,
&[' ', 'x'],
&|_: &str, _: Option<u16>| ImageSlot::Unavailable,
&|_: &str| MermaidSlot::Image { cols: 20, rows: 5 },
"mermaid",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|s| is_code_header_span(s))
.count()
}
#[test]
fn code_block_scanner_counts_exactly_what_the_renderer_draws() {
let cases: &[(&str, &str)] = &[
("plain fence", "```rust\nfn a(){}\n```\n"),
("two fences", "```\na\n```\n\n```\nb\n```\n"),
("tilde fence", "~~~\na\n~~~\n"),
("in alert", "> [!NOTE]\n> ```rust\n> fn a(){}\n> ```\n"),
(
"alert then plain",
"> [!NOTE]\n> ```\n> in\n> ```\n\n```\nout\n```\n",
),
(
"details closed",
"<details>\n<summary>S</summary>\n\n```\nhidden\n```\n\n</details>\n",
),
(
"details open",
"<details open>\n<summary>S</summary>\n\n```\nshown\n```\n\n</details>\n",
),
(
"mermaid is not a code block",
"```mermaid\nflowchart TD\nA-->B\n```\n\n```\nreal\n```\n",
),
(
"fence with tasks around",
"- [ ] t\n\n```\ncode\n```\n\n- [x] u\n",
),
(
"mermaid inside an alert is a code block",
"> [!NOTE]\n> ```mermaid\n> flowchart TD\n> A-->B\n> ```\n",
),
(
"mermaid in alert + plain fence",
"> [!NOTE]\n> ```mermaid\n> A-->B\n> ```\n\n```\nreal\n```\n",
),
(
"fence containing a table lookalike",
"```text\n| a | b |\n|---|---|\n| 1 | 2 |\n```\n",
),
(
"fence nested inside an alert nested inside a closed details is not counted",
"<details>\n<summary>S</summary>\n\n> [!NOTE]\n> ```\n> hidden\n> ```\n\n</details>\n",
),
(
"fence nested inside an alert nested inside an open details is counted",
"<details open>\n<summary>S</summary>\n\n> [!NOTE]\n> ```\n> shown\n> ```\n\n</details>\n",
),
(
"fence nested inside a details nested inside an alert, closed, is not counted",
"> [!NOTE]\n> <details>\n> <summary>S</summary>\n>\n\
> ```\n> hidden\n> ```\n>\n> </details>\n",
),
(
"fence nested inside a details nested inside an alert, open, is counted",
"> [!NOTE]\n> <details open>\n> <summary>S</summary>\n>\n\
> ```\n> shown\n> ```\n>\n> </details>\n",
),
];
for (name, src) in cases {
set_details_open(Vec::new());
let drawn = rendered_code_blocks(src);
let scanned = code_block_source_locs(src, &[]).len();
assert_eq!(
drawn, scanned,
"{name}: 画面のコードブロック数とコピー用スキャナの数が食い違う\
(この文書では `y c` が全部拒否される)\n--- src ---\n{src}"
);
}
}
#[test]
fn task_scanner_matches_renderer_across_indented_code_corpus() {
for (name, src) in code_corpus::cases() {
set_details_open(Vec::new());
let drawn = rendered_tasks(src);
let scanned = task_source_locs(src, &[' ', 'x'], &[]).len();
if PLAIN_QUOTE_TASK_SCANNER_GAP.contains(&name) {
assert_eq!(
(drawn, scanned),
(1, 0),
"{name}: 既知のプレーン引用チェックボックス差異の形が変わった\
(PLAIN_QUOTE_TASK_SCANNER_GAP のコメント参照)\n--- src ---\n{src}"
);
continue;
}
assert_eq!(
drawn, scanned,
"{name}: 画面のチェックボックス数と書き戻しスキャナの数が食い違う\
(この文書ではトグルが全部中止される)\n--- src ---\n{src}"
);
}
}
fn rendered_texts(src: &str) -> Vec<String> {
set_details_open(Vec::new());
let (lines, _, _extras) = render_markdown_with_images(
src,
100,
CodeStyle::default(),
"TwoDark",
false,
&[' ', 'x'],
&|_: &str, _: Option<u16>| ImageSlot::Inline { cols: 10, rows: 2 },
&|_: &str| MermaidSlot::Image { cols: 20, rows: 5 },
"mermaid",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
lines
.iter()
.map(|l| l.spans.iter().map(|sp| sp.content.as_ref()).collect())
.collect()
}
#[test]
fn an_indented_code_block_is_code_whatever_its_content_looks_like() {
for content in [
"<code>",
"<div>",
"<span>x</span>",
"<details>",
"<summary>S</summary>",
"<kbd>Ctrl</kbd>",
"<br>",
"<!-- a comment -->",
"</a>",
"<a href=\"x\">",
"<仕様書>",
"<https://example.com>",
] {
let src = format!("para\n\n {content}\n");
set_details_open(Vec::new());
assert_eq!(
rendered_code_blocks(&src),
1,
"{content:?}: 字下げコードブロックがコードとして描かれていない\
(HTML ブロックとして救出され、タグが剥がれている)\n--- src ---\n{src}"
);
let texts = rendered_texts(&src);
assert!(
texts
.iter()
.any(|t| t.starts_with('▎') && t.contains(content)),
"{content:?}: ガター付きの行に本文が見当たらない: {texts:#?}"
);
set_details_open(Vec::new());
assert_eq!(
code_block_source_locs(&src, &[]),
vec![content.to_string()],
"{content:?}: コピー用スキャナが描画と一致しない\n--- src ---\n{src}"
);
}
}
#[test]
fn a_centered_banner_stays_html_even_though_its_cut_fragments_read_as_indented_code() {
let fragment = " </a>\n <a href=\"https://example.com/x\">\n";
let frag_lines: Vec<&str> = fragment.lines().collect();
assert_eq!(
splitter_code_mask(&frag_lines),
vec![true, true],
"前提: この2行だけを渡せば(正しく)字下げコードブロックと判定される"
);
let banner = concat!(
"<p align=\"center\">\n",
" <a href=\"https://example.com/y\">\n",
" <img src=\"https://img.example/a.svg\" alt=\"badge a\">\n",
" </a>\n",
" <a href=\"https://example.com/x\">\n",
" <img src=\"https://img.example/b.svg\" alt=\"badge b\">\n",
" </a>\n",
"</p>\n",
"\ntail\n",
);
let doc_lines: Vec<&str> = banner.lines().collect();
assert!(
splitter_code_mask(&doc_lines).iter().all(|c| !c),
"前提: 同じ2行でも文書全体で見れば HTML ブロックの内側=コードではない\
(この非対称こそが、分割後の断片からマスクを引き直せない理由)"
);
set_details_open(Vec::new());
let drawn = rendered_code_blocks(banner);
let texts = rendered_texts(banner);
assert_eq!(
drawn, 0,
"バナーが字下げコードブロックとして描かれている: {texts:#?}"
);
set_details_open(Vec::new());
assert!(
code_block_source_locs(banner, &[]).is_empty(),
"コピー用スキャナがバナーをコードブロックとして数えている"
);
for alt in ["badge a", "badge b"] {
assert!(
texts.iter().any(|t| t.contains(alt)),
"バッジ {alt:?} が失われている: {texts:#?}"
);
}
assert!(
!texts
.iter()
.any(|t| t.contains("</a>") || t.contains("<p ")),
"生の HTML タグが画面に漏れている: {texts:#?}"
);
}
#[test]
fn the_document_mask_gates_image_extraction_but_not_mermaid_or_a_peeled_details_body() {
for (name, content) in [
("markdown image", ""),
("html image", "<img src=\"x.png\" alt=\"i\">"),
] {
let src = format!("para\n\n {content}\n\ntail\n");
set_details_open(Vec::new());
assert_eq!(
rendered_code_blocks(&src),
1,
"{name}: 字下げブロックがコードとして描かれていない\n--- src ---\n{src}"
);
let texts = rendered_texts(&src);
assert!(
texts
.iter()
.any(|t| t.starts_with('▎') && t.contains(content)),
"{name}: 画像として抜き出されてしまい、本文が残っていない: {texts:#?}"
);
}
let (_, places, _) = {
set_details_open(Vec::new());
render_markdown_with_images(
"para\n\n```mermaid\nflowchart TD\nA-->B\n```\n\ntail\n",
100,
CodeStyle::default(),
"TwoDark",
false,
&[' ', 'x'],
&|_: &str, _: Option<u16>| ImageSlot::Inline { cols: 10, rows: 2 },
&|_: &str| MermaidSlot::Image { cols: 20, rows: 5 },
"mermaid",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
)
};
assert_eq!(
places.len(),
1,
"mermaid フェンスが図として抜き出されていない(コードマスクで塞いでしまった)"
);
assert_eq!(places[0].fence_ord, Some(0), "フェンス序数は 0");
let details =
"<details open>\n<summary>S</summary>\n\n```rust\nfn a(){}\n```\n\n</details>\n";
set_details_open(collect_details_open(details));
assert_eq!(
rendered_code_blocks(details),
1,
"剥がした details 本文の中のフェンスがコードとして描かれていない\
(本文は独立した文書として parse し直す必要がある)"
);
}
fn code_body_row_texts(src: &str) -> Vec<String> {
set_details_open(Vec::new());
let (lines, _, _extras) = render_markdown_with_images(
src,
100,
CodeStyle::default(),
"TwoDark",
false,
&[' ', 'x'],
&|_: &str, _: Option<u16>| ImageSlot::Unavailable,
&|_: &str| MermaidSlot::Image { cols: 20, rows: 5 },
"mermaid",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
lines
.iter()
.filter(|l| is_code_line(l) && !l.spans.first().is_some_and(is_code_header_span))
.map(|l| l.to_string().trim_start_matches('▎').trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
#[test]
fn list_item_fence_body_lines_stay_split_on_screen() {
let top_level = code_body_row_texts("```\naaa\nbbb\n```\n");
assert_eq!(
top_level,
vec!["aaa".to_string(), "bbb".to_string()],
"対照ケース(トップレベルのフェンス)が想定どおり分かれていない\
(直したいのはリスト項目内だけのはず)\n--- rows ---\n{top_level:?}"
);
let in_list = code_body_row_texts("1. item:\n\n ```\n aaa\n bbb\n ```\n");
assert_eq!(
in_list,
vec!["aaa".to_string(), "bbb".to_string()],
"リスト項目内のフェンスの本文行が画面上で1行に連結されている\
(この文書のコードブロックが1行に潰れて表示される)\n--- rows ---\n{in_list:?}"
);
}
#[test]
fn code_block_content_matches_the_render_for_fence_edge_cases() {
assert_eq!(
code_block_source_locs("para\n\n ```rust\n fn a(){}\n ```\n", &[]),
vec!["```rust\nfn a(){}\n```".to_string()],
"4カラム字下げは本物のフェンスではなく字下げコードブロック本体。\
バッククォートの記号ごと文字どおりの本文として返る(画面表示と一致)"
);
assert_eq!(
code_block_source_locs("para\n\n ```rust\n fn a(){}\n ```\n", &[]),
vec!["fn a(){}".to_string()],
"3カラム字下げは本物のフェンス。中身はフェンス自身のインデント分だけ剥がれる\
(画面表示と一致・4カラムのケースとの違いが本質)"
);
assert_eq!(
code_block_source_locs("```rust\nbody\n```js\nmore\n```\n", &[]),
vec!["body\n```js\nmore".to_string()],
"info文字列つきの`閉じ風`の行(```js)は閉じない=本文としてそのまま残る"
);
assert_eq!(
code_block_source_locs("~~~~md\n~~~\ninner\n~~~\n~~~~\n", &[]),
vec!["~~~\ninner\n~~~".to_string()],
"4チルダの中の3チルダは長さ不足で閉じない=本文として残る"
);
assert_eq!(
code_block_source_locs("```rust\n~~~\nnot closing\n~~~\n```\n", &[]),
vec!["~~~\nnot closing\n~~~".to_string()],
"バッククォートフェンスの中のチルダ風の行は文字種が違うので閉じない"
);
assert_eq!(
code_block_source_locs("````rust\nbody\n````\n", &[]),
vec!["body".to_string()],
"ネストの無い単純な4バッククォートも問題なく動く"
);
assert_eq!(
code_block_source_locs("1. Fork it:\n\n ```sh\n git clone x\n ```\n", &[]),
vec!["git clone x".to_string()],
"リスト項目内のフェンスは項目の content 列ぶんの字下げが剥がれて返る"
);
assert_eq!(
code_block_source_locs("- outer\n - inner\n\n code line\n", &[]),
vec!["code line".to_string()],
"入れ子項目内の字下げコードは(項目の content 列+4)ぶんが剥がれて返る"
);
}
#[test]
fn task_prefix_state_accepts_gfm_spacing_and_reports_the_state_offset() {
let st = [' ', 'x'];
assert_eq!(task_prefix_state("- [ ] a", &st), Some((' ', 3)));
assert_eq!(task_prefix_state("* [x] a", &st), Some(('x', 3)));
assert_eq!(task_prefix_state("+ [X] a", &st), Some(('X', 3)));
assert_eq!(task_prefix_state("* [ ] a", &st), Some((' ', 5)));
assert_eq!(task_prefix_state("- [ ] a", &st), Some((' ', 6)));
assert_eq!(task_prefix_state("- [x]", &st), Some(('x', 3)));
assert_eq!(task_prefix_state("- [x]\t", &st), Some(('x', 3)));
assert_eq!(task_prefix_state("- [ ] a", &st), None);
assert_eq!(task_prefix_state("-[ ] a", &st), None);
assert_eq!(task_prefix_state("1. [ ] a", &st), None);
assert_eq!(task_prefix_state("- [?] a", &st), None);
assert_eq!(task_prefix_state("- [ ]x", &st), None);
for line in ["- [ ] a", "* [x] a", "- [ ] a", "- [x]"] {
let (state, off) = task_prefix_state(line, &st).unwrap();
assert_eq!(
line[off..].chars().next(),
Some(state),
"{line}: state_off が状態文字を指していない"
);
}
}
#[test]
fn code_block_content_is_correct_for_a_nested_shorter_backtick_fence() {
let src = "````md\n```mermaid\ninner\n```\n````\n";
assert_eq!(
code_block_source_locs(src, &[]),
vec!["```mermaid\ninner\n```".to_string()],
"スキャナ自身は正しく1ブロック・中身も真の CommonMark 解釈と一致する"
);
assert_eq!(
rendered_code_blocks(src),
1,
"レンダラも1ヘッダのみ描く(以前は内側のマーカーを閉じと誤認して2ヘッダ描いていた)"
);
let src_with_tail =
"````md\n```mermaid\ninner\n```\n````\n\n# Heading After\n\nReal prose here.\n";
assert_eq!(
rendered_code_blocks(src_with_tail),
1,
"末尾に本文があってもヘッダは1つのまま"
);
let lines = render_markdown_tasks(
src_with_tail,
100,
CodeStyle::default(),
"TwoDark",
false,
&[' ', 'x'],
);
assert!(
lines
.iter()
.any(|l| heading_text(l).as_deref() == Some("Heading After")),
"フェンス以降の見出しがコードブロックに飲まれず、見出しとして描かれる\
\n--- rendered ---\n{lines:?}"
);
}
#[test]
fn task_scan_finds_the_real_task_outside_a_correctly_nested_fence() {
let src = "````md\n```mermaid\n- [ ] fake\n```\n````\n\n- [ ] real\n";
let locs = task_source_locs(src, &[' ', 'x'], &[]);
assert_eq!(
locs.len(),
1,
"フェンス内の `- [ ] fake` は本文=非タスクとして無視される"
);
let lines: Vec<&str> = src.lines().collect();
assert_eq!(
lines[locs[0].line], "- [ ] real",
"見つかった1件はフェンス外の本物のタスク行"
);
assert_eq!(
rendered_tasks(src),
1,
"レンダラも1件だけ描く(以前は内側のマーカーの誤認でフェンス以降が丸ごとコードに\
飲まれ0件になっていた)"
);
}
#[test]
fn process_inline_html_leaves_a_nested_shorter_fence_untouched() {
let src = "````md\n```mermaid\n<kbd>Ctrl</kbd>\n```\n````\n";
assert_eq!(
process_inline_html(src),
src,
"フェンス内の <kbd> は書き換わらない(文書全体が不変のまま)"
);
let indented = "para\n\n ```rust\n <kbd>Ctrl</kbd>\n ```\n";
assert_eq!(process_inline_html(indented), indented);
let mixed = "```rust\n~~~\n<kbd>Ctrl</kbd>\n~~~\n```\n";
assert_eq!(process_inline_html(mixed), mixed);
}
#[test]
fn process_footnotes_leaves_refs_inside_a_nested_shorter_fence_literal() {
let src = "````md\n```mermaid\n[^1]\n```\n````\n\n[^1]: note\n";
assert_eq!(
process_footnotes(src),
src,
"フェンス内の [^1] は本物の参照ではないので置換されない\
(フェンス外に本物の参照も無いので、未使用の定義は無変換のまま残る)"
);
let indented = "para\n\n ```rust\n [^1]\n ```\n\n[^1]: note\n";
assert_eq!(process_footnotes(indented), indented);
}
#[test]
fn split_details_ignores_a_details_lookalike_inside_a_nested_shorter_fence() {
let src = "````md\n```mermaid\n<details>lookalike</details>\n```\n````\n";
let parts = split_details(&doc_run(src));
assert_eq!(
parts.len(),
1,
"文書全体が1つの Text パートのまま(details として切り出されない)"
);
match &parts[0] {
DetailsPart::Text(t) => assert_eq!(t.text(), src),
DetailsPart::Details { .. } => {
panic!("フェンス内の <details> 風の行を本物の details として切り出してしまった")
}
}
}
#[test]
fn indented_code_block_strips_four_columns_and_keeps_the_blank_between_chunks() {
let src = "para\n\n chunk one\n\n chunk two\n";
let blocks = code_block_source_locs(src, &[]);
assert_eq!(
blocks.len(),
1,
"2つのチャンクは1つのコードブロックにグルーされる"
);
assert_eq!(
blocks[0], "chunk one\n\nchunk two",
"4カラムだけ除去され、チャンク間の空行はソースどおり残る"
);
let extra = "para\n\n extra indent kept\n";
assert_eq!(
code_block_source_locs(extra, &[]),
vec![" extra indent kept".to_string()],
"4カラムを超える字下げは本文としてそのまま残る"
);
let tabbed = "para\n\n\ttabbed line\n";
assert_eq!(
code_block_source_locs(tabbed, &[]),
vec!["tabbed line".to_string()],
"タブ1個は4カラム分として丸ごと除去される"
);
}
#[test]
fn indented_code_is_scoped_out_of_an_active_list_but_resumes_once_it_ends() {
assert!(
code_block_source_locs("- item\n\n still list content\n", &[]).is_empty(),
"リスト項目内の字下げはコードとして検出しない"
);
assert_eq!(
code_block_source_locs("- item\n\ntop\n\n code\n", &[]),
vec!["code".to_string()],
"リストが素の段落で終わった後の字下げは通常どおりコードとして検出する"
);
}
#[test]
fn heading_gap_is_fixed_and_plain_blockquote_gap_has_no_mismatch() {
let heading_then_code = "# H\n now detected\n";
assert_eq!(
rendered_code_blocks(heading_then_code),
1,
"レンダラは見出し直後(空行なし)でも字下げコードを描く"
);
assert_eq!(
code_block_source_locs(heading_then_code, &[]),
vec!["now detected".to_string()],
"見出し直後(空行なし)の字下げコードも検出され、中身も画面表示と一致する\
(以前は「既知の制約」として空行必須のまま検出しなかった)"
);
let quoted = "> para\n>\n> code\n";
assert_eq!(
rendered_code_blocks(quoted),
1,
"モデルレンダラは素の(アラートでない)引用内の字下げコードにもヘッダを描く\
(意図的な優位=parser_code_blocks の上位互換)"
);
set_details_open(Vec::new());
let (_, _, extras) = render_markdown_with_images(
quoted,
100,
CodeStyle::default(),
"TwoDark",
false,
&[' ', 'x'],
&|_: &str, _: Option<u16>| ImageSlot::Unavailable,
&|_: &str| MermaidSlot::Image { cols: 20, rows: 5 },
"mermaid",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
assert_eq!(
extras.code_blocks,
vec!["code".to_string()],
"`y c` がコピーする中身は引用の `>` が残らず正しい"
);
assert!(
code_block_source_locs(quoted, &[]).is_empty(),
"レガシースキャナは依然検出しない — が、この文書は model 経路なのでスキャナは \
`y c` に一切関与しない(参考の記録として残す・新規リグレッションではない)"
);
}
#[test]
fn heading_and_thematic_break_detectors_match_commonmark_boundaries() {
assert!(is_atx_heading_line("# H"));
assert!(is_atx_heading_line("## H"));
assert!(is_atx_heading_line("###### H"));
assert!(
is_atx_heading_line("#"),
"本文なしの単独 # も見出し(EOL 扱い)"
);
assert!(is_atx_heading_line("#\t"), "# の直後がタブでも見出し");
assert!(
!is_atx_heading_line("####### H"),
"7個の # は見出しではない(1-6個まで)"
);
assert!(
!is_atx_heading_line("#nospace"),
"# の直後が空白/EOLでなければ見出しではない"
);
assert!(!is_atx_heading_line("plain text"));
assert!(
!is_atx_heading_line(" # H"),
"4カラム以上の字下げは見出しではなく字下げコードの本文候補"
);
assert!(is_atx_heading_line(" # H"), "3カラムまでの字下げは許容");
assert!(is_thematic_break_line("---"));
assert!(is_thematic_break_line("***"));
assert!(is_thematic_break_line("___"));
assert!(is_thematic_break_line("- - -"), "空白区切りの3個も水平線");
assert!(is_thematic_break_line("****"), "4個以上でも水平線");
assert!(
!is_thematic_break_line("--"),
"2個は水平線ではない(3個必要)"
);
assert!(!is_thematic_break_line("**"), "アスタリスク2個も同様");
assert!(
!is_thematic_break_line("- - "),
"マーカーが2個しかなければ水平線ではない"
);
assert!(
!is_thematic_break_line("--x--"),
"マーカー以外の文字が混ざれば水平線ではない"
);
assert!(
!is_thematic_break_line(" ---"),
"4カラム以上の字下げは水平線ではなく字下げコードの本文候補"
);
assert!(is_setext_underline_line("====="));
assert!(is_setext_underline_line("="), "1文字の = も形としては該当");
assert!(is_setext_underline_line("-"), "1文字の - も形としては該当");
assert!(is_setext_underline_line("--"), "2文字の - も形としては該当");
assert!(!is_setext_underline_line("=-="), "= と - が混ざれば非該当");
assert!(!is_setext_underline_line(""), "空行は非該当");
}
#[test]
fn thematic_break_and_setext_underline_open_indented_code_without_a_blank_line() {
let cases: &[(&str, &str, &str)] = &[
("thematic break (---)", "---\n code here\n", "code here"),
("thematic break (***)", "***\n star code\n", "star code"),
(
"setext level-1 heading (=====)",
"Title\n=====\n code here\n",
"code here",
),
(
"setext level-2 heading, underline length >= 3 (-----)",
"Title\n-----\n code here\n",
"code here",
),
(
"setext level-2 heading, short underline (--), too short to be a thematic break on its own",
"Title\n--\n code here\n",
"code here",
),
(
"setext level-2 heading, single-dash underline (-)",
"Title\n-\n code here\n",
"code here",
),
];
for (name, src, want) in cases {
let drawn = rendered_code_blocks(src);
assert_eq!(
drawn, 1,
"{name}: レンダラの前提(1ブロック描画)がまず崩れている\n--- src ---\n{src}"
);
let scanned = code_block_source_locs(src, &[]);
assert_eq!(
scanned,
vec![want.to_string()],
"{name}: 画面のコードブロック数とスキャナの数・内容が食い違う\n--- src ---\n{src}"
);
}
}
#[test]
fn heading_rule_setext_detection_does_not_regress_lazy_continuation_or_list_scoping() {
let cases: &[(&str, &str)] = &[
(
"plain paragraph then lazy continuation (unrelated to this fix, still must hold)",
"para\n not code, just continues the paragraph\n",
),
(
"indented content inside an active list item stays out of scope",
"- item\n\n still item content, not code\n",
),
(
"7 hashes is not a heading, so the paragraph it starts still gates the next indented line",
"####### not a heading\n still just the paragraph continuing\n",
),
(
"a bare 2-dash run at document start is neither a thematic break nor (nothing to \
attach to) a setext underline, so it's an ordinary paragraph that still gates",
"--\n still just the paragraph continuing\n",
),
(
"a bare 1-dash run at document start, same reasoning",
"-\n still just the paragraph continuing\n",
),
];
for (name, src) in cases {
let drawn = rendered_code_blocks(src);
assert_eq!(
drawn, 0,
"{name}: レンダラの前提(0ブロック=段落継続)がまず崩れている\n--- src ---\n{src}"
);
assert!(
code_block_source_locs(src, &[]).is_empty(),
"{name}: スキャナが誤ってコードとして検出した(逆方向の食い違い)\n--- src ---\n{src}"
);
}
}
#[test]
fn alert_code_block_is_copied_without_the_quote_prefix() {
let src = "> [!NOTE]\n> ```rust\n> fn a() {}\n> let x = 1;\n> ```\n";
let blocks = code_block_source_locs(src, &[]);
assert_eq!(blocks.len(), 1, "アラート内のフェンスを1件拾う");
assert_eq!(
blocks[0], "fn a() {}\nlet x = 1;",
"`>` を剥がした素のコードがコピーされる"
);
}
#[test]
fn every_located_offset_points_at_its_state_char() {
for (name, src) in task_corpus::cases() {
let lines: Vec<&str> = src.lines().collect();
for loc in task_source_locs(src, &[' ', 'x'], &[]) {
let line = lines[loc.line];
assert!(
line.is_char_boundary(loc.state_off),
"{name}: state_off が文字境界でない: {line:?}"
);
assert_eq!(
line[loc.state_off..].chars().next(),
Some(loc.state),
"{name}: state_off が状態文字を指していない: {line:?}"
);
}
}
}
#[test]
fn fence_containing_html_lookalike_stays_code() {
set_details_open(Vec::new());
let src = "```html\n<div class=\"x\">\nhello\n</div>\n```\n";
let lines = render_markdown_tasks(src, 100, CodeStyle::default(), "TwoDark", false, &[]);
assert_eq!(
lines
.iter()
.filter(|l| l
.spans
.iter()
.any(|s| s.content.as_ref() == "```"))
.count(),
0,
"閉じフェンスの `` ``` `` が生テキストとして漏れてはいけない\n--- rendered ---\n{lines:?}"
);
let hello_line = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.contains("hello")))
.unwrap_or_else(|| {
panic!("'hello' がどこにも描画されていない\n--- rendered ---\n{lines:?}")
});
assert!(
is_code_line(hello_line),
"フェンス内の 'hello' はコードとして描かれるはず(HTML ブロック救出に横取りされていない)\
\n--- line ---\n{hello_line:?}"
);
}
#[test]
fn fence_containing_alert_lookalike_stays_code() {
set_details_open(Vec::new());
let src = "```text\n> [!NOTE]\nlooks like an alert\n```\n";
let lines = render_via_dispatcher(
&doc_run(src),
100,
CodeStyle::default(),
"TwoDark",
false,
&[],
true, );
assert!(
!lines
.iter()
.any(|l| l.spans.first().is_some_and(|s| s.content.starts_with('▌'))),
"アラートの左バー(▌)が出てはいけない(フェンス内の `> [!NOTE]` は素のコード)\
\n--- rendered ---\n{lines:?}"
);
let note_line = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.contains("[!NOTE]")))
.unwrap_or_else(|| {
panic!("'[!NOTE]' がどこにも描画されていない\n--- rendered ---\n{lines:?}")
});
assert!(
is_code_line(note_line),
"フェンス内の '[!NOTE]' 行はコードとして描かれるはず\n--- line ---\n{note_line:?}"
);
}
}
#[cfg(test)]
mod fence_and_math_extraction_tests {
use super::*;
#[test]
fn mermaid_fence_extraction_is_source_ordered_and_stable() {
let cases: &[(&str, &str, &[&str])] = &[
("single", "```mermaid\nA-->B\n```\n", &["A-->B\n"]),
(
"two in order",
"```mermaid\nfirst\n```\n\ntext\n\n```mermaid\nsecond\n```\n",
&["first\n", "second\n"],
),
(
"non-mermaid fences are skipped",
"```rust\nfn a(){}\n```\n\n```mermaid\ndiagram\n```\n",
&["diagram\n"],
),
(
"info string with attributes",
"```mermaid theme=dark\nbody\n```\n",
&["body\n"],
),
("tilde fence", "~~~mermaid\ntilde\n~~~\n", &["tilde\n"]),
(
"unterminated is kept",
"```mermaid\nno close\n",
&["no close\n"],
),
("empty body", "```mermaid\n```\n", &[]),
(
"multi-line body kept verbatim",
"```mermaid\nflowchart TD\n A-->B\n```\n",
&["flowchart TD\n A-->B\n"],
),
(
"inside an alert: not image-ized, so not listed",
"> [!NOTE]\n> ```mermaid\n> A-->B\n> ```\n",
&[],
),
];
for (name, src, want) in cases {
let got = collect_mermaid_fences(src);
assert_eq!(
got,
want.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
"{name}: mermaid フェンスの抽出結果が期待と違う\n--- src ---\n{src}"
);
}
}
#[test]
fn math_extraction_covers_delimiters_and_rejects_lookalikes() {
let display = |s: &str| (s.to_string(), true);
let inline = |s: &str| (s.to_string(), false);
type MathCase = (&'static str, &'static str, Vec<(String, bool)>);
let cases: &[MathCase] = &[
("inline dollars", "a $x+1$ b\n", vec![inline("x+1")]),
("display same line", "$$x+1$$\n", vec![display("x+1")]),
(
"display on its own lines",
"$$\n x+1\n$$\n",
vec![display("x+1")],
),
(
"display tight form is not math (known limit)",
"$$x+1\n$$\n",
vec![],
),
("inline paren", "a \\(y\\) b\n", vec![inline("y")]),
("display bracket", "\\[z\\]\n", vec![display("z")]),
(
"two inline",
"$a$ and $b$\n",
vec![inline("a"), inline("b")],
),
("currency is not math", "costs $5 and $7 today\n", vec![]),
("escaped dollar", "\\$not math\\$\n", vec![]),
("inside inline code", "`$x$`\n", vec![]),
("inside a fence", "```\n$x$\n```\n", vec![]),
(
"inside an indented code block",
"para\n\n $x$ stays literal\n\npara2\n",
vec![],
),
("empty is not math", "$$\n", vec![]),
(
"inside an alert",
"> [!NOTE]\n> here is $x^2$ math\n",
vec![],
),
(
"inside a blockquote",
"> plain quote $x^2$ inside\n",
vec![],
),
(
"inside details",
"<details>\n<summary>S</summary>\n\nhere is $x^2$ math\n\n</details>\n",
vec![],
),
(
"inside an HTML block nested in details",
"<details>\n<summary>S</summary>\n\n<p align=\"center\">\n $x^2$ stays put\n <br>\n tail\n</p>\n\n</details>\n",
vec![],
),
(
"inside an HTML block nested in a blockquote",
"> <p align=\"center\">\n> $x^2$ stays put\n> <br>\n> tail\n> </p>\n",
vec![],
),
(
"inside a details fence with no blank line before it",
"<details>\n<summary>s</summary>\n```rust\n[^1] <kbd>K</kbd> $x$\n```\n</details>\n\noutside [^1] and <kbd>K</kbd> and $x$\n\n[^1]: def\n",
vec![inline("x")],
),
(
"inside a table row",
"| formula | value |\n|---|---|\n| $x^2$ | 4 |\n",
vec![],
),
(
"after a table it is still math",
"| a | b |\n|---|---|\n| 1 | 2 |\n\n$x$ still math\n",
vec![inline("x")],
),
];
for (name, src, want) in cases {
let got = collect_math_exprs(src);
assert_eq!(
&got, want,
"{name}: 数式抽出が期待と違う\n--- src ---\n{src}"
);
}
}
#[test]
fn math_inside_alert_keeps_the_callout_intact() {
set_details_open(Vec::new());
let md = "# H\n\n> [!NOTE]\n> here is $x^2$ math\n> more alert text\n\nTail paragraph.\n";
let (lines, imgs, _extras) = render_markdown_with_images(
md,
60,
CodeStyle::default(),
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_: &str, _: Option<u16>| ImageSlot::Unavailable,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Image { cols: 8, rows: 2 },
true,
);
assert!(
imgs.is_empty(),
"math inside an alert must stay literal, not become an image placement: {imgs:?}"
);
let joined: Vec<String> = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect();
let full = joined.join("\n");
assert!(
!full.contains("> more alert text"),
"the alert's own blockquote marker must never leak out of the callout box as raw \
text: {full:?}"
);
let note_idx = joined
.iter()
.position(|l| l.contains("Note"))
.expect("alert header line ('Note') must be present");
let tail_idx = joined
.iter()
.position(|l| l.contains("Tail paragraph."))
.expect("the trailing paragraph must still render");
assert!(
tail_idx > note_idx,
"the tail must come after the alert box"
);
for l in &joined[note_idx..tail_idx] {
if l.trim().is_empty() {
continue;
}
assert!(
l.contains('▌'),
"every alert line (header + body) must carry the callout bar: {l:?}\nfull:\n{full}"
);
}
}
#[test]
fn math_inside_closed_details_stays_hidden() {
set_details_open(Vec::new());
let md = "# H\n\n<details>\n<summary>Click to expand</summary>\n\nhere is $x^2$ math\n\nSECRET-SHOULD-BE-HIDDEN\n\n</details>\n\nTail paragraph.\n";
let (lines, _imgs, _extras) = render_markdown_with_images(
md,
60,
CodeStyle::default(),
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_: &str, _: Option<u16>| ImageSlot::Unavailable,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Image { cols: 8, rows: 2 },
true,
);
let full: String = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
!full.contains("SECRET-SHOULD-BE-HIDDEN"),
"a closed <details> body must stay hidden behind the collapsed marker — this is an \
information-disclosure regression if it appears: {full:?}"
);
}
#[test]
fn math_inside_table_keeps_the_grid() {
set_details_open(Vec::new());
let md = "# H\n\n| formula | value |\n|---|---|\n| $x^2$ | 4 |\n| plain | 5 |\n\nTail paragraph.\n";
let (lines, imgs, _extras) = render_markdown_with_images(
md,
60,
CodeStyle::default(),
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_: &str, _: Option<u16>| ImageSlot::Unavailable,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Image { cols: 8, rows: 2 },
true,
);
assert!(
imgs.is_empty(),
"math inside a table cell must stay literal, not become an image placement: {imgs:?}"
);
let full: String = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(full.contains('┌'), "table top border must render: {full:?}");
assert!(
full.contains('└'),
"table bottom border must render: {full:?}"
);
assert!(
full.contains("$x^2$"),
"the math cell can't become an image inside a table, so it renders its raw LaTeX \
literally: {full:?}"
);
assert!(
!full.contains("| 4 |"),
"no row must fall out of the table grid as raw, un-drawn text: {full:?}"
);
}
#[test]
fn math_inside_blockquote_is_left_literal() {
set_details_open(Vec::new());
let md = "> plain quote $x^2$ inside\n> second line\n";
let (lines, imgs, _extras) = render_markdown_with_images(
md,
60,
CodeStyle::default(),
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_: &str, _: Option<u16>| ImageSlot::Unavailable,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Image { cols: 8, rows: 2 },
true,
);
assert!(
imgs.is_empty(),
"math inside a blockquote must stay literal, not become an image placement: {imgs:?}"
);
let full: String = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
full.contains("quote $x^2$ inside second line"),
"the quote must stay one unbroken block (its second line joined onto the first, not \
split into two separate quotes with a stray paragraph in between): {full:?}"
);
}
#[test]
fn math_outside_structures_still_renders() {
set_details_open(Vec::new());
let md = "> [!NOTE]\n> here is $x^2$ math\n\n$y^2$ outside\n";
let (_lines, imgs, _extras) = render_markdown_with_images(
md,
60,
CodeStyle::default(),
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_: &str, _: Option<u16>| ImageSlot::Unavailable,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Image { cols: 8, rows: 2 },
true,
);
assert_eq!(
imgs.len(),
1,
"the alert's math stays suppressed but the top-level math is still lifted: {imgs:?}"
);
assert!(
is_math_url(&imgs[0].url),
"the one placement must be the top-level math expression: {imgs:?}"
);
}
}
#[cfg(test)]
mod mask_boundary_tests {
use super::*;
type Expect = (bool, bool, bool);
struct Row {
name: &'static str,
doc: String,
expect: Vec<Expect>,
}
fn row(name: &'static str, doc: impl Into<String>, expect: Vec<Expect>) -> Row {
Row {
name,
doc: doc.into(),
expect,
}
}
fn rows() -> Vec<Row> {
let ind3 = " ".repeat(3);
let ind4 = " ".repeat(4);
let ind5 = " ".repeat(5);
let ind6 = " ".repeat(6);
let ind8 = " ".repeat(8);
const F: bool = false;
const T: bool = true;
vec![
row(
"3 columns is not a code block (ordinary paragraph)",
format!("{ind3}TEXT"),
vec![(F, F, F)],
),
row(
"4 columns is a code block",
format!("{ind4}TEXT"),
vec![(F, T, T)],
),
row(
"8 columns is still one code block (the extra 4 are content)",
format!("{ind8}TEXT"),
vec![(F, T, T)],
),
row(
"a tab is 4 columns, same as 4 spaces",
"\tTEXT",
vec![(F, T, T)],
),
row(
"after a paragraph (blank line required; the blank line itself is not part of the block)",
format!("para\n\n{ind4}TEXT"),
vec![(F, F, F), (F, F, F), (F, T, T)],
),
row(
"right after an ATX heading, no blank line needed (a heading cannot absorb a continuation line)",
format!("# H\n{ind4}TEXT"),
vec![(F, F, F), (F, T, T)],
),
row(
"right after a setext heading underline, no blank line needed",
format!("Heading\n=====\n{ind4}TEXT"),
vec![(F, F, F), (F, F, F), (F, T, T)],
),
row(
"4 columns inside a list item ('- ' content column 2) is short of the 6 the item needs \
— still a paragraph continuation, not a code block",
format!("- item\n{ind4}TEXT"),
vec![(F, F, F), (F, F, F)],
),
row(
"6 columns inside a list item, blank line before it, is a code block",
format!("- item\n\n{ind6}TEXT"),
vec![(F, F, F), (F, F, F), (F, T, T)],
),
row(
"6 columns inside a list item with NO blank line before it is still just a paragraph \
continuation (extra indentation on a continuation line does not start a nested block)",
format!("- item\n{ind6}TEXT"),
vec![(F, F, F), (F, F, F)],
),
row(
"indented code block as the very first line of a blockquote (quote's own content column, \
'> ' = 1, plus 4 = 5)",
format!(">{ind5}TEXT"),
vec![(F, T, T)],
),
row(
"indented code block inside a blockquote, after a blank quote line",
format!("> plain\n>\n>{ind5}TEXT"),
vec![(F, F, F), (F, F, F), (F, T, T)],
),
row(
"no blank quote line first: still just a continuation of the quote's own paragraph",
format!("> plain\n>{ind5}TEXT"),
vec![(F, F, F), (F, F, F)],
),
row(
"two indented chunks separated by a blank line — the blank line is part of the block too",
format!("{ind4}A\n\n{ind4}B"),
vec![(F, T, T), (F, T, T), (F, T, T)],
),
row(
"a fence indented 2 columns at top level is still a fence",
" ```\n code\n ```",
vec![(T, T, T), (T, T, T), (T, T, T)],
),
row(
"a fence indented 4 absolute columns under a '1. ' item (content column 3, so only 1 \
relative column) is a REAL fence — fence_mask's absolute-column check misses it \
(leading_ws_width >= 4 rejects the opener outright), code_block_mask (container-aware, \
via pulldown-cmark) catches it",
format!("1. item\n\n{ind4}```\n{ind4}code\n{ind4}```"),
vec![(F, F, F), (F, F, F), (F, T, T), (F, T, T), (F, T, T)],
),
row(
"a nested longer fence (```` ```` ```` wrapping ``` ```) stays open the whole way through, \
by both masks",
"````md\n```\ninner\n```\n````",
vec![(T, T, T), (T, T, T), (T, T, T), (T, T, T), (T, T, T)],
),
row(
"an unclosed fence runs to end of file, by both masks",
"```\ncode",
vec![(T, T, T), (T, T, T)],
),
row(
"<details><summary>…</summary> immediately followed by a fence, no blank line: \
code_block_mask says NOT code (pulldown-cmark reads the whole thing as one HTML block); \
fence_mask says code (blind to the surrounding <details>/<summary> context); the union \
(literal_code_mask) is what matches what the renderer actually draws — a real code block, \
because split_details hands this body to an isolated re-parse where nothing competes",
"<details>\n<summary>s</summary>\n```rust\nCODE\n```\n</details>",
vec![
(F, F, F), (F, F, F), (T, F, T), (T, F, T), (T, F, T), (F, F, F), ],
),
row(
"<div>…</div> wrapping a fence, no blank line: code_block_mask says NOT code — \
pulldown-cmark reads the whole <div>...</div> as one HTML block, and konoma's own \
renderer agrees (split_html_blocks carves this out as HTML before the parser ever sees \
it, not as a code block)",
"<div>\n```\ncode\n```\n</div>",
vec![
(F, F, F), (T, F, T), (T, F, T), (T, F, T), (F, F, F), ],
),
row(
"no code block anywhere: front matter, heading, paragraph, list, quote, table, HTML \
block, footnote definition",
"---\ntitle: T\n---\n\n# Heading\n\npara text\n\n- list item\n\n> quote\n\n| a | b |\n\
|---|---|\n| 1 | 2 |\n\n<div>html</div>\n\n[^1]: def",
vec![
(F, F, F), (F, F, F), (F, F, F), (F, F, F), (F, F, F), (F, F, F), (F, F, F), (F, F, F), (F, F, F), (F, F, F), (F, F, F), (F, F, F), (F, F, F), (F, F, F), (F, F, F), (F, F, F), (F, F, F), (F, F, F), (F, F, F), ],
),
]
}
#[test]
fn masks_agree_with_pulldown_cmark_line_by_line() {
for r in rows() {
let lines: Vec<&str> = r.doc.lines().collect();
assert_eq!(
lines.len(),
r.expect.len(),
"{}: doc has {} lines but {} expectations were given — doc={:?}",
r.name,
lines.len(),
r.expect.len(),
r.doc
);
let fm = fence_mask(&lines);
let cbm = code_block_mask(&lines);
let lcm = literal_code_mask(&lines);
for (i, &(ef, eb, el)) in r.expect.iter().enumerate() {
assert_eq!(
fm[i], ef,
"{}: line {i} {:?} — fence_mask expected {ef}, got {}",
r.name, lines[i], fm[i]
);
assert_eq!(
cbm[i], eb,
"{}: line {i} {:?} — code_block_mask expected {eb}, got {}",
r.name, lines[i], cbm[i]
);
assert_eq!(
lcm[i], el,
"{}: line {i} {:?} — literal_code_mask expected {el}, got {}",
r.name, lines[i], lcm[i]
);
assert_eq!(
lcm[i],
fm[i] || cbm[i],
"{}: line {i} literal_code_mask must equal fence_mask OR code_block_mask",
r.name
);
}
}
}
}
#[cfg(test)]
pub(crate) mod preprocess_corpus {
pub fn cases() -> Vec<(&'static str, &'static str)> {
vec![
("def single line", "Ref[^a].\n\n[^a]: one line.\n"),
("def continued 2 cols", "Ref[^a].\n\n[^a]: first\n second\n"),
("def continued 4 cols", "Ref[^a].\n\n[^a]: first\n second\n"),
(
"def continued 6 cols with a wrapped link",
"Ref[^a].\n\n[^a]: [label](\n https://example.com/x)\n",
),
("def lazy continuation", "Ref[^a].\n\n[^a]: first\nsecond\n"),
(
"def with two paragraphs",
"Ref[^a].\n\n[^a]: first\n\n second para\n",
),
(
"def carrying a fence",
"Ref[^a].\n\n[^a]: see\n\n ```rust\n fn a() {}\n ```\n",
),
(
"def carrying a list",
"Ref[^a].\n\n[^a]: see\n\n - one\n - two\n",
),
(
"def carrying a checkbox",
"Ref[^a].\n\n[^a]: see\n\n - [ ] inside the note\n",
),
(
"two defs both continued",
"A[^a] and B[^b].\n\n[^a]: first\n more\n\n[^b]: second\n more\n",
),
("def never referenced", "No reference here.\n\n[^a]: orphan\n"),
("ref with no def", "Dangling[^zz] reference.\n"),
("same ref twice", "One[^a] two[^a].\n\n[^a]: shared\n"),
(
"ref inside a code span stays literal",
"Text `[^a]` literal.\n\n[^a]: def\n",
),
(
"def inside a fence stays literal",
"```\n[^a]: not a def\n```\n\nRef[^a].\n",
),
(
"def inside indented code stays literal",
"para\n\n [^a]: not a def\n\nRef[^a].\n",
),
(
"continued def before a fence",
"Ref[^a].\n\n[^a]: first\n more\n\n```\ncode\n```\n",
),
(
"continued def after a fence",
"```\ncode\n```\n\nRef[^a].\n\n[^a]: first\n more\n",
),
(
"continued def before indented code",
"Ref[^a].\n\n[^a]: first\n more\n\npara\n\n indented\n",
),
(
"continued def between two fences",
"```\none\n```\n\nRef[^a].\n\n[^a]: first\n more\n\n~~~\ntwo\n~~~\n",
),
("br plain", "before<br>after\n"),
("br slash", "before<br/>after\n"),
("br spaced slash", "before<br />after\n"),
("br uppercase", "before<BR>after\n"),
("br making a list interrupt a paragraph", "prose<br>- [ ] injected\n"),
("br before a fence", "prose<br>more\n\n```\ncode\n```\n"),
("br after a fence", "```\ncode\n```\n\nprose<br>more\n"),
("br inside a fence is literal", "```\na<br>b\n```\n"),
("br inside indented code is literal", "para\n\n a<br>b\n"),
("br inside a code span is literal", "text `a<br>b` more\n"),
("br on a checkbox line", "- [ ] task<br>tail\n"),
("kbd", "Press <kbd>Ctrl</kbd> now.\n"),
("kbd inside a code span is literal", "Write `<kbd>Ctrl</kbd>` so.\n"),
("del", "This <del>was</del> that.\n"),
("s tag", "This <s>was</s> that.\n"),
("strike tag", "This <strike>was</strike> that.\n"),
("sup", "x<sup>2</sup>\n"),
("sub", "H<sub>2</sub>O\n"),
("empty del makes tildes", "a<del></del>b\n"),
("empty del alone becomes a fence opener", "before\n\n<del></del>\n\nafter\n"),
(
"br ending an alert early above a fence",
"> [!NOTE]\n> Some prose with <br> inline text.\n>\n> ```\n> code line one\n> ```\n",
),
("kbd on a checkbox line", "- [ ] press <kbd>x</kbd>\n"),
("kbd before a fence", "<kbd>x</kbd>\n\n```\ncode\n```\n"),
("paired tag inside a fence is literal", "```\n<kbd>x</kbd>\n```\n"),
("checkbox carrying a ref", "- [ ] task[^a]\n\n[^a]: note\n"),
(
"checkbox carrying a ref and a continued def",
"- [ ] task[^a]\n\n[^a]: note\n more\n",
),
(
"continued def inside a list item",
"- item\n\n Ref[^a].\n\n[^a]: first\n more\n",
),
(
"continued def inside a nested list item",
"- outer\n - inner Ref[^a].\n\n[^a]: first\n more\n",
),
("br inside a list item", "- item<br>tail\n"),
("br inside a nested list item", "- outer\n - inner<br>tail\n"),
("br inside a quote", "> quoted<br>tail\n"),
("br inside an alert", "> [!NOTE]\n> quoted<br>tail\n"),
(
"br inside a table cell",
"| a | b |\n| --- | --- |\n| x<br>y | z |\n",
),
(
"bare br line inside an html block",
"<p align=\"center\">\n <a href=\"https://a\">\n <img src=\"https://a.svg\" alt=\"a\">\n </a>\n <br>\n <a href=\"https://b\">\n <img src=\"https://b.svg\" alt=\"b\">\n </a>\n</p>\n",
),
(
"mid-line br inside an html block",
"<div align=\"center\">\n before<br>after\n</div>\n",
),
(
"continued def inside a quote",
"> Ref[^a].\n\n[^a]: first\n more\n",
),
(
"alert with a fence and a continued def after it",
"> [!NOTE]\n> ```\n> code\n> ```\n\nRef[^a].\n\n[^a]: first\n more\n",
),
(
"open details with a fence and a continued def",
"<details open>\n<summary>S</summary>\n\n```\ncode\n```\n\n</details>\n\nRef[^a].\n\n[^a]: first\n more\n",
),
(
"closed details with a fence and a continued def",
"<details>\n<summary>S</summary>\n\n```\ncode\n```\n\n</details>\n\nRef[^a].\n\n[^a]: first\n more\n",
),
("br inside an open details", "<details open>\n<summary>S</summary>\n\nprose<br>tail\n\n</details>\n"),
(
"open details whose body starts with indented code",
"<details open>\n<summary>S</summary>\n\n indented line\n\n</details>\n",
),
(
"open details with prose before indented code",
"<details open>\n<summary>S</summary>\n\nprose first.\n\n indented line\n\n</details>\n",
),
(
"checkbox in an alert plus a continued def",
"> [!NOTE]\n> - [ ] task\n\nRef[^a].\n\n[^a]: first\n more\n",
),
(
"front matter then a continued def",
"---\ntitle: t\n---\n\nRef[^a].\n\n[^a]: first\n more\n",
),
(
"front matter then br",
"---\ntitle: t\n---\n\nprose<br>tail\n",
),
("crlf with a continued def", "Ref[^a].\r\n\r\n[^a]: first\r\n more\r\n"),
("crlf with br", "prose<br>tail\r\n"),
("no trailing newline continued def", "Ref[^a].\n\n[^a]: first\n more"),
("no trailing newline br", "prose<br>tail"),
("cjk continued def", "参照[^a]。\n\n[^a]: 最初の行\n 続きの行\n"),
("cjk br", "前<br>後\n"),
("cjk checkbox with a ref", "- [ ] やること[^a]\n\n[^a]: 注記\n"),
(
"angle brackets holding cjk next to a fence",
"Use <仕様書> and <資料dir>.\n\n```\n/groundwork <仕様書> <NotionURL> <資料dir>\n```\n",
),
(
"angle brackets holding cjk with a continued def",
"Use <仕様書>[^a].\n\n[^a]: <NotionURL> の説明\n 続き\n",
),
(
"cjk fence plus br plus continued def",
"前<br>後\n\n```\n/groundwork <仕様書> <NotionURL> <資料dir>\n```\n\n参照[^a]。\n\n[^a]: 注記\n 続き\n",
),
(
"rand_chacha readme shape",
"ChaCha[^1], used as an RNG.\n\nselected by eSTREAM[^2].\n\nLinks:\n\n- [API](https://docs.rs/rand_chacha)\n\n[rand]: https://crates.io/crates/rand\n[^1]: D. J. Bernstein, [*ChaCha*](\n https://cr.yp.to/chacha.html)\n\n[^2]: [eSTREAM](\n http://www.ecrypt.eu.org/stream/)\n\n\n## Crate Features\n",
),
(
"shlex quoting_warning shape",
"Text[^1] with a fence.\n\n```sh\necho hi\n```\n\n[^1]: A note that wraps\n onto a second line.\n",
),
(
"br injected checkbox above a del-fence swallowed one",
"prose<br>- [ ] INJECTED\n\n<del></del>\n\n- [ ] SWALLOWED\n",
),
(
"footnote leftover plus br injected checkbox",
"[^1]: def text\n - [ ] LEFTOVER\n\n- [ ] REAL\n\nprose<br>- [ ] INJECTED\n\nSee[^1].\n",
),
(
"centered banner with a bare br line (registry: raw-window-metal README.md)",
"<p align=\"center\">\n <a href=\"https://crates.io/crates/rwm\">\n <img src=\"https://img.example/v.svg\" alt=\"crates.io\">\n </a>\n <br>\n <a href=\"LICENSE-MIT\">\n <img src=\"https://img.example/mit.svg\" alt=\"License - MIT\">\n </a>\n</p>\n\ntail\n",
),
(
"markdown block image above a centered banner (registry: static_assertions README.md)",
"[](https://example.com/repo)\n\n<div align=\"center\">\n <a href=\"https://crates.io/crates/sa\">\n <img src=\"https://img.example/v.svg\" alt=\"Crates.io\">\n </a>\n <img src=\"https://img.example/rustc.svg\" alt=\"rustc\">\n <br>\n <a href=\"https://example.com/patron\">\n <img src=\"https://img.example/patron.png\" alt=\"Patron\">\n </a>\n</div>\n\ntail.\n",
),
(
"crlf centered banner with no br (registry: tinytemplate README.md)",
"<h1 align=\"center\">TT</h1>\r\n\r\n<div align=\"center\">\r\n <a href=\"https://example.com/actions\">\r\n <img src=\"https://img.example/ci.svg\" alt=\"CI\">\r\n </a>\r\n <a href=\"https://crates.io/crates/tt\">\r\n <img src=\"https://img.example/v.svg\" alt=\"Crates.io\">\r\n </a>\r\n</div>\r\n\r\ntail\r\n",
),
(
"tag-shaped indented code beside the same tag outside it",
"para\n\n <kbd>Ctrl</kbd>\n\nPress <kbd>Ctrl</kbd> now.\n",
),
(
"closing-tag indented code beside a real html block",
"<div align=\"center\">\n <b>hi</b>\n</div>\n\npara\n\n </a>\n",
),
(
"comment-shaped indented code beside a real comment block",
"<!-- real block -->\n\npara\n\n <!-- a comment -->\n",
),
(
"autolink-shaped indented code beside a real autolink",
"para\n\n <https://example.com>\n\nSee <https://example.com> too.\n",
),
(
"cjk angle brackets inside indented code and outside it",
"para\n\n <仕様書>\n\n本文で <仕様書> に触れる。\n",
),
(
"void-tag indented code beside a real br",
"para\n\n <br>\n\nprose<br>tail\n",
),
]
}
}
#[cfg(test)]
pub(crate) mod inline_corpus {
pub fn cases() -> Vec<(&'static str, &'static str)> {
vec` literally.\n",
),
("link inline", "See [the docs](https://example.com/docs).\n"),
(
"link inline with title",
"See [the docs](https://example.com/docs \"Docs\").\n",
),
(
"link with relative path destination",
"See [the guide](./docs/guide.md) for setup.\n",
),
(
"link destination is an anchor",
"Jump to [Usage](#usage) below.\n",
),
(
"link label containing emphasis",
"See [**bold label**](https://example.com/x).\n",
),
(
"reference link with no definition stays literal",
"See [the docs][missing] here.\n",
),
(
"shortcut reference with no definition stays literal",
"See [missing] here.\n",
),
(
"reference link with definition in the next block",
"See [the docs][ref] here.\n\n[ref]: https://example.com/docs\n",
),
(
"shortcut reference with definition in the next block",
"See [ref] here.\n\n[ref]: https://example.com/docs\n",
),
("autolink", "Visit <https://example.com/> today.\n"),
(
"autolink mailto",
"Contact <mailto:hello@example.com> today.\n",
),
(
"inline image alt text shown mid-paragraph",
"Look at this:  closely.\n",
),
(
"inline image with no alt text",
"Look at this:  closely.\n",
),
("soft break", "line one\nline two\n"),
("hard break via two trailing spaces", "line one \nline two\n"),
("hard break via trailing backslash", "line one\\\nline two\n"),
("heading level 1", "# Title One\n"),
("heading level 2", "## Title Two\n"),
("heading level 3", "### Title Three\n"),
("heading level 4", "#### Title Four\n"),
("heading level 5", "##### Title Five\n"),
("heading level 6", "###### Title Six\n"),
(
"heading containing strong and code",
"## Setup with **care** and `cargo run`\n",
),
(
"heading with attributes",
"## Custom Heading {#custom-id .note lang=en}\n",
),
("setext heading level 1", "Big Title\n=========\n"),
("setext heading level 2", "Smaller Title\n-------------\n"),
("thematic break dashes", "---\n"),
("thematic break asterisks", "***\n"),
("thematic break underscores", "___\n"),
(
"thematic break between two paragraphs",
"before.\n\n---\n\nafter.\n",
),
("superscript", "x^2^ is x squared.\n"),
("subscript", "H~2~O is water.\n"),
("escaped asterisks are literal", "This is \\*not emphasized\\*.\n"),
(
"escaped brackets are literal",
"This is \\[not a link](nope).\n",
),
("escaped backslash", "A literal backslash: \\\\ done.\n"),
("named entity amp", "Fish \\& chips, or: fish & chips.\n"),
("named entity copy", "All rights © 2026.\n"),
("cjk paragraph", "これは日本語の段落です。特殊な記法は使っていません。\n"),
("cjk heading", "# 日本語の見出し\n"),
(
"cjk emphasis touching the delimiters",
"日本語*強調*語のテキスト。\n",
),
(
"cjk emphasis after full-width punctuation",
"これは、*強調*です。\n",
),
(
"cjk link label",
"詳細は[日本語のラベル](https://example.com/ja)を参照。\n",
),
("empty document", ""),
("whitespace-only line produces no block", " \n"),
(
"very long single-line paragraph",
"word 通常のテキスト word 通常のテキスト word 通常のテキスト word 通常のテキスト word 通常のテキスト word 通常のテキスト word 通常のテキスト word 通常のテキスト word 通常のテキスト word 通常のテキスト\n",
),
(
"many consecutive blank lines between paragraphs collapse the same as one",
"first.\n\n\n\n\nsecond.\n",
),
]
}
}
#[cfg(test)]
pub(crate) mod list_corpus {
pub fn cases() -> Vec<(&'static str, &'static str)> {
vec![
("unordered bullet dash", "- a\n- b\n"),
("unordered bullet star", "* a\n* b\n"),
("unordered bullet plus", "+ a\n+ b\n"),
("ordered dot delimiter", "1. a\n2. b\n"),
("ordered paren delimiter", "1) a\n2) b\n"),
("ordered list does not start at one", "5. a\n6. b\n7. c\n"),
(
"ordered list source numbers skip midway are still auto-numbered",
"1. a\n5. b\n9. c\n",
),
("tight unordered list two items", "- a\n- b\n"),
("loose unordered list two items", "- a\n\n- b\n"),
("tight ordered list two items", "1. a\n2. b\n"),
("loose ordered list two items", "1. a\n\n2. b\n"),
(
"loose item has two paragraphs of its own",
"- a\n\n b\n- c\n",
),
("nested bullet two levels", "- a\n - b\n"),
("nested bullet three levels", "- a\n - b\n - c\n"),
(
"ordered list nested inside an unordered one",
"- a\n 1. b\n 2. c\n",
),
(
"unordered list nested inside an ordered one",
"1. a\n - b\n - c\n",
),
("quote containing a list", "> - a\n> - b\n"),
(
"list containing a quote in its own item",
"- a\n\n > quoted\n- b\n",
),
(
"block quote can interrupt a tight item's own paragraph with no blank line",
"- a\n > quoted\n",
),
("nested quotes two levels", "> a\n>> b\n"),
("quote containing a heading", "> # Title\n> body\n"),
(
"quote containing a thematic break",
"> above\n>\n> ---\n>\n> below\n",
),
(
"quote containing a table",
"> | a | b |\n> |---|---|\n> | 1 | 2 |\n",
),
(
"alert containing a table with no blank line before it",
"> [!IMPORTANT]\n> a | b\n> ---|---\n> 1 | 2\n",
),
("tight unordered task unchecked", "- [ ] a\n"),
("tight unordered task checked lowercase", "- [x] a\n"),
(
"tight unordered task checked uppercase collapses to lowercase x",
"- [X] a\n",
),
("tight star-bullet task", "* [ ] a\n"),
("tight plus-bullet task", "+ [ ] a\n"),
("tight ordered task list", "1. [ ] a\n2. [x] b\n"),
(
"task item nested under a tight item",
"- [ ] a\n - [x] b\n",
),
("task item inside a loose list", "- a\n\n- [ ] b\n"),
(
"cjk tight unordered list items",
"- 最初の項目\n- 二番目の項目\n",
),
("cjk ordered list items", "1. 最初\n2. 二番目\n"),
("cjk task item", "- [ ] 買い物に行く\n"),
("cjk quote", "> これは引用です。\n"),
("single-item list", "- only\n"),
("empty list item followed by a real one", "- \n- b\n"),
(
"tight list item that is only a standalone image",
"- \n",
),
(
"two tight list items each only a standalone image",
"- \n- \n",
),
(
"list followed by a paragraph after a blank line",
"- a\n- b\n\nafter.\n",
),
(
"paragraph followed by a list after a blank line",
"before.\n\n- a\n- b\n",
),
]
}
}
#[cfg(test)]
pub(crate) mod html_table_corpus {
pub fn cases() -> Vec<(&'static str, &'static str)> {
vec</td></tr>\n</table>\n",
),
(
"cell with html b i code",
"<table>\n<tr><td><b>bold</b> <i>italic</i> <code>code</code></td></tr>\n</table>\n",
),
(
"cell with html strong em",
"<table>\n<tr><td><strong>bold</strong> <em>italic</em></td></tr>\n</table>\n",
),
(
"cell with an anchor",
"<table>\n<tr><td><a href=\"https://example.com\">site</a></td></tr>\n</table>\n",
),
(
"cell with an anchor that has no href",
"<table>\n<tr><td><a name=\"x\">anchor</a></td></tr>\n</table>\n",
),
(
"cell with an unclosed anchor",
"<table>\n<tr><td><a href=\"https://example.com\">site</td></tr>\n</table>\n",
),
(
"cell with a br",
"<table>\n<tr><td>first<br>second</td></tr>\n</table>\n",
),
(
"cell with the tags the inline-html pre-pass owns",
"<table>\n<tr><td><del>gone</del> <kbd>Ctrl</kbd> H<sub>2</sub>O x<sup>2</sup></td></tr>\n</table>\n",
),
(
"cell with an html entity",
"<table>\n<tr><td>a & b <c></td></tr>\n</table>\n",
),
(
"cell with an html comment",
"<table>\n<tr><td>before<!-- hidden -->after</td></tr>\n</table>\n",
),
(
"cell with an unknown tag",
"<table>\n<tr><td><span class=\"x\">text</span></td></tr>\n</table>\n",
),
(
"cell with a literal pipe",
"<table>\n<tr><td>a | b</td></tr>\n</table>\n",
),
("cjk cells", "<table>\n<tr><th>名前</th><th>数</th></tr>\n<tr><td>りんご</td><td>三</td></tr>\n</table>\n"),
(
"cell content spread over several physical lines",
"<table>\n<tr>\n<td>\nfirst line\nsecond line\n</td>\n</tr>\n</table>\n",
),
(
"ragged rows",
"<table>\n<tr><td>a</td><td>b</td><td>c</td></tr>\n<tr><td>d</td></tr>\n</table>\n",
),
("empty cells", "<table>\n<tr><td></td><td>b</td></tr>\n</table>\n"),
(
"an empty row before a real one",
"<table>\n<tr></tr>\n<tr><td>a</td><td>b</td></tr>\n</table>\n",
),
(
"an empty row between the header row and the body",
"<table>\n<tr><th>H</th></tr>\n<tr></tr>\n<tr><td>a</td></tr>\n</table>\n",
),
(
"a table of nothing but empty rows stays plain html",
"<table>\n<tr></tr><tr></tr>\n</table>\n",
),
(
"empty table with no cells stays plain html",
"<table>\n</table>\n",
),
(
"table with only a caption stays plain html",
"<table>\n<caption>Just a caption</caption>\n</table>\n",
),
(
"caption alongside real rows keeps the table unfolded",
"<table>\n<caption>LOOSE-caption</caption>\n<tr><td>apple</td></tr>\n</table>\n",
),
(
"a stray line straight inside the table and the row",
"<table>\n LOOSE-before-row\n <tr>\n LOOSE-in-row\n <td>cell</td>\n </tr>\n</table>\n",
),
(
"prose between one cell and the next",
"<table>\n<tr><td>a</td>LOOSE-between<td>b</td></tr>\n</table>\n",
),
(
"a stray line inside a thead wrapper",
"<table>\n<thead>\nLOOSE-in-thead\n<tr><th>H</th></tr></thead>\n<tbody><tr><td>a</td></tr></tbody>\n</table>\n",
),
(
"loose text inside a quoted table",
"> <table>\n> LOOSE-quoted\n> <tr><td>a</td></tr>\n> </table>\n",
),
(
"whitespace and newlines outside the cells still fold",
"<table>\n <tr>\n <td>a</td>\n <td>b</td>\n </tr>\n</table>\n",
),
(
"a comment outside the cells still folds",
"<table>\n<!-- SECRET-outside -->\n<tr><td>keep</td></tr>\n</table>\n",
),
(
"thead tbody tfoot wrappers",
"<table>\n<thead><tr><th>H</th></tr></thead>\n<tbody><tr><td>B</td></tr></tbody>\n<tfoot><tr><td>F</td></tr></tfoot>\n</table>\n",
),
(
"colgroup and col",
"<table>\n<colgroup><col><col></colgroup>\n<tr><td>a</td><td>b</td></tr>\n</table>\n",
),
(
"uppercase tags",
"<TABLE>\n<TR><TH>H</TH></TR>\n<TR><TD>b</TD></TR>\n</TABLE>\n",
),
(
"single quoted attributes",
"<table>\n<tr><td align='center'>C</td></tr>\n<tr><td>wide cell here</td></tr>\n</table>\n",
),
(
"unquoted attribute value",
"<table>\n<tr><td align=center>C</td></tr>\n<tr><td>wide cell here</td></tr>\n</table>\n",
),
(
"omitted closing td and tr tags",
"<table>\n<tr><td>a<td>b\n<tr><td>c<td>d\n</table>\n",
),
(
"td outside any tr",
"<table>\n<td>a</td><td>b</td>\n</table>\n",
),
(
"colspan is drawn as one plain cell",
"<table>\n<tr><td colspan=\"2\">spanning</td></tr>\n<tr><td>a</td><td>b</td></tr>\n</table>\n",
),
(
"rowspan is drawn as one plain cell",
"<table>\n<tr><td rowspan=\"2\">spanning</td><td>a</td></tr>\n<tr><td>b</td></tr>\n</table>\n",
),
(
"nested table is flattened into its enclosing cell",
"<table>\n<tr><td><table><tr><td>inner</td></tr></table></td><td>outer</td></tr>\n</table>\n",
),
(
"blank line inside the table is not a table",
"<table>\n<tr>\n\n<td>a</td>\n</tr>\n</table>\n",
),
(
"unclosed table is not a table",
"<table>\n<tr><td>a</td></tr>\n",
),
(
"table not the first tag in the block is not a table",
"<div>\n<table>\n<tr><td>a</td></tr>\n</table>\n</div>\n",
),
(
"table inside a block quote",
"> <table>\n> <tr><td>a</td><td>b</td></tr>\n> </table>\n",
),
(
"table inside a block quote with a multi line cell",
"> <table>\n> <tr>\n> <td>\n> first line\n> second line\n> </td>\n> </tr>\n> </table>\n",
),
(
"table inside details",
"<details>\n<summary>More</summary>\n\n<table>\n<tr><th>H</th></tr>\n<tr><td>b</td></tr>\n</table>\n\n</details>\n",
),
(
"table inside an open details",
"<details open>\n<summary>More</summary>\n\n<table>\n<tr><th>H</th></tr>\n<tr><td>b</td></tr>\n</table>\n\n</details>\n",
),
(
"table inside a list item",
"- item\n\n <table>\n <tr><td>a</td><td>b</td></tr>\n </table>\n",
),
(
"paragraph glued directly after the table keeps the block unfolded",
"<table>\n<tr><td>a</td></tr>\n</table>\nafter\n",
),
(
"paragraph after the table across a blank line",
"<table>\n<tr><td>a</td></tr>\n</table>\n\nafter\n",
),
(
"fence directly after the table",
"<table>\n<tr><td>a</td></tr>\n</table>\n\n```rust\nlet x = 1;\n```\n",
),
(
"two tables in a row",
"<table>\n<tr><td>a</td></tr>\n</table>\n\n<table>\n<tr><td>b</td></tr>\n</table>\n",
),
(
"gfm table with only a header row",
"| a |\n|---|\n",
),
(
"gfm table and html table in the same document",
"| a | b |\n|---|--:|\n| 1 | 2 |\n\n<table>\n<tr><td>c</td><td align=\"right\">d</td></tr>\n</table>\n",
),
(
"a commented-out row",
"<table>\n<tr><td>keep</td></tr>\n<!-- <tr><td>SECRET-row</td></tr> -->\n</table>\n",
),
(
"a commented-out row across several lines",
"<table>\n<tr><td>keep</td></tr>\n<!--\n<tr><td>SECRET-multiline</td></tr>\n-->\n</table>\n",
),
(
"a commented-out thead",
"<table>\n<!--\n<thead><tr><th>SECRET-head</th></tr></thead>\n-->\n<tbody><tr><td>keep</td></tr></tbody>\n</table>\n",
),
(
"a commented-out td inside a live row",
"<table>\n<tr><td>keep</td><!-- <td>SECRET-cell</td> --></tr>\n</table>\n",
),
(
"a comment inline before a cell on the same line",
"<table>\n<tr><!-- <td>SECRET-inline</td> --><td>keep</td></tr>\n</table>\n",
),
(
"a comment that opens in a cell and closes past its end tag",
"<table>\n<tr><td>keep <!-- </td></tr><tr><td>SECRET-crossing --> tail</td></tr>\n</table>\n",
),
(
"a comment containing the table's own closing tag",
"<table>\n<tr><td>keep</td></tr>\n<!-- </table> -->\n<tr><td>second</td></tr>\n</table>\n",
),
(
"a comment containing a second comment opener",
"<table>\n<tr><td>keep</td></tr>\n<!-- <tr><td>SECRET-outer</td></tr> <!-- <tr><td>SECRET-inner</td></tr> -->\n<tr><td>after the comment</td></tr>\n</table>\n",
),
(
"a comment that is never closed",
"<table>\n<tr><td>keep</td></tr>\n<!-- <tr><td>SECRET-unclosed</td></tr>\n</table>\n",
),
(
"a table whose only row is commented out",
"<table>\n<!-- <tr><td>SECRET-only</td></tr> -->\n</table>\n",
),
(
"an attribute value that looks like a comment opener",
"<table>\n<tr><td title=\"<!--\">keep</td></tr>\n</table>\n",
),
(
"an attribute value that looks like a whole comment",
"<table>\n<tr><td title=\"<!-- x -->\">keep</td></tr>\n</table>\n",
),
(
"a comment indented, with blank space around it",
"<table>\n <!--\n <tr><td>SECRET-spaced</td></tr>\n --> \n <tr><td>keep</td></tr>\n</table>\n",
),
(
"a commented-out row inside a quoted table",
"> <table>\n> <tr><td>keep</td></tr>\n> <!-- <tr><td>SECRET-quoted</td></tr> -->\n> </table>\n",
),
(
"a comment glued onto the block after the close",
"<table>\n<tr><td>keep</td></tr>\n</table>\n<!-- trailing -->\n",
),
(
"a comment containing a nested table",
"<table>\n<tr><td>keep</td></tr>\n<!-- <table><tr><td>SECRET-nested</td></tr></table> -->\n<tr><td>second</td></tr>\n</table>\n",
),
]
}
}
#[cfg(test)]
mod app_faithful_parity_tests {
use super::*;
fn app_pre(raw: &str) -> (String, LineOrigin) {
let body = strip_front_matter(raw).1;
let origin = identity_origin(&body);
let (s, origin) = process_footnotes_traced(&body, &origin);
process_inline_html_traced(&s, &origin)
}
fn drawn_tasks(src: &str) -> usize {
set_details_open(Vec::new());
let lines = render_markdown_tasks(
src,
100,
CodeStyle::default(),
"TwoDark",
false,
&[' ', 'x'],
);
lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|s| is_task_span(s))
.count()
}
const CHANGES_THE_BLOCK_SET: &[&str] = &[
"empty del alone becomes a fence opener",
"br injected checkbox above a del-fence swallowed one",
"br ending an alert early above a fence",
];
fn all_cases() -> Vec<(&'static str, &'static str)> {
let mut v = preprocess_corpus::cases();
v.extend(code_corpus::cases());
v.extend(task_corpus::cases());
v
}
fn model_code_blocks(src: &str) -> Vec<String> {
set_details_open(Vec::new());
let (_, _, extras) = render_markdown_with_images(
src,
100,
CodeStyle::default(),
"TwoDark",
false,
&[' ', 'x'],
&|_: &str, _: Option<u16>| ImageSlot::Unavailable,
&|_: &str| MermaidSlot::Image { cols: 20, rows: 5 },
"mermaid",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
extras.code_blocks
}
#[test]
fn code_scanner_matches_the_render_through_the_app_pipeline() {
for (name, raw) in all_cases() {
let (pre, _) = app_pre(raw);
let body = strip_front_matter(raw).1;
let from_pre = model_code_blocks(&pre);
let from_body = model_code_blocks(&body);
if from_pre.len() != from_body.len() {
assert!(
CHANGES_THE_BLOCK_SET.contains(&name),
"{name}: 前処理がコードブロックの集合を変えたが既知の理由が無い\
(この類型の新しい実例の可能性)\n--- raw ---\n{raw}\n--- preprocessed ---\n{pre}\
\nfrom_body={from_body:?}\nfrom_pre={from_pre:?}"
);
continue;
}
assert_eq!(
from_pre, from_body,
"{name}: 前処理の有無でレンダラが記録する中身がバイト単位で変わる\
(`y c` が前処理の有無で違う内容をコピーする)\n--- raw ---\n{raw}"
);
}
}
#[test]
fn task_scanner_matches_the_render_through_the_app_pipeline() {
for (name, raw) in all_cases() {
let (pre, _) = app_pre(raw);
let drawn = drawn_tasks(&pre);
let scanned = task_source_locs(&pre, &[' ', 'x'], &[]).len();
if task_scan_parity_tests::PLAIN_QUOTE_TASK_SCANNER_GAP.contains(&name) {
assert_eq!(
(drawn, scanned),
(1, 0),
"{name}: 既知のプレーン引用チェックボックス差異の形が変わった\
(PLAIN_QUOTE_TASK_SCANNER_GAP のコメント参照)\
\n--- raw ---\n{raw}\n--- preprocessed ---\n{pre}"
);
continue;
}
assert_eq!(
drawn, scanned,
"{name}: アプリ経路で画面のチェックボックス数と書き戻しスキャナの数が食い違う\
\n--- raw ---\n{raw}\n--- preprocessed ---\n{pre}"
);
}
}
#[test]
fn copying_from_the_preprocessed_text_yields_the_files_own_bytes() {
for (name, raw) in all_cases() {
let (pre, _) = app_pre(raw);
let body = strip_front_matter(raw).1;
let from_pre = code_block_source_locs(&pre, &[]);
let from_raw = code_block_source_locs(&body, &[]);
if from_pre.len() != from_raw.len() {
assert!(
CHANGES_THE_BLOCK_SET.contains(&name),
"{name}: 前処理がコードブロックの集合を変えたが既知の理由が無い\
(この類型の新しい実例の可能性)\n--- raw ---\n{raw}\n--- preprocessed ---\n{pre}"
);
continue;
}
assert_eq!(
from_pre, from_raw,
"{name}: 前処理の有無でコピーされる中身がバイト単位で変わる\
\n--- raw ---\n{raw}"
);
}
}
#[test]
fn line_origins_have_exactly_one_entry_per_preprocessed_line() {
for (name, raw) in all_cases() {
let (pre, origin) = app_pre(raw);
assert_eq!(
origin.len(),
pre.lines().count(),
"{name}: origin の要素数が前処理後の行数と一致しない\
(以降の行の書き戻し先が全てずれる)\n--- raw ---\n{raw}\n--- preprocessed ---\n{pre}"
);
}
}
#[test]
fn every_drawn_checkbox_either_resolves_exactly_or_not_at_all() {
for (name, raw) in all_cases() {
let (pre, origin) = app_pre(raw);
let body = strip_front_matter(raw).1;
let body_lines: Vec<&str> = body.lines().collect();
let pre_lines: Vec<&str> = pre.lines().collect();
for l in task_source_locs(&pre, &[' ', 'x'], &[]) {
let Some(src_line) = origin.get(l.line).copied().flatten() else {
continue; };
let end = l.state_off + l.state.len_utf8();
let (Some(on_screen), Some(on_disk)) = (
pre_lines.get(l.line).and_then(|x| x.get(..end)),
body_lines.get(src_line).and_then(|x| x.get(..end)),
) else {
continue; };
if on_screen != on_disk {
continue; }
assert_eq!(
body_lines[src_line][l.state_off..end].chars().next(),
Some(l.state),
"{name}: 解決した行の書き込み位置が状態文字ではない\
\n--- raw ---\n{raw}"
);
}
}
}
}
#[cfg(test)]
mod block_align_tests {
use super::*;
use unicode_width::UnicodeWidthStr;
const W: u16 = 60;
const ALL: [BlockAlign; 3] = [BlockAlign::Left, BlockAlign::Center, BlockAlign::Right];
fn render(
src: &str,
width: u16,
aligns: BlockAligns,
) -> (Vec<Line<'static>>, Vec<ImagePlacement>) {
let slot_of = |_: &str, max: Option<u16>| ImageSlot::Inline {
cols: match max {
Some(m) => m.min(6),
None => 10,
},
rows: 2,
};
let mermaid_slot = |_: &str| MermaidSlot::Image { cols: 20, rows: 5 };
let math_slot = |_: &str, _: bool| MathSlot::Image { cols: 10, rows: 2 };
let (lines, images, _) = render_markdown_with_images_aligned(
src,
width,
CodeStyle::default(),
"TwoDark",
false,
DEFAULT_TASK_STATES,
&slot_of,
&mermaid_slot,
"Enter: full screen",
true,
&math_slot,
true,
aligns,
);
(lines, images)
}
fn aligns(table: BlockAlign, image: BlockAlign) -> BlockAligns {
BlockAligns { table, image }
}
fn text(line: &Line<'static>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
fn indent_of(line: &Line<'static>) -> usize {
let t = text(line);
t.len() - t.trim_start_matches(' ').len()
}
fn table_box(lines: &[Line<'static>]) -> (usize, usize) {
let l = lines
.iter()
.find(|l| text(l).trim_start().starts_with('┌'))
.expect("no table drawn");
(indent_of(l), text(l).trim_start().width())
}
const GFM: &str = "| a | bbbb |\n| --- | --- |\n| 1 | 2 |\n";
const HTML: &str =
"<table>\n<tr><th>a</th><th>bbbb</th></tr>\n<tr><td>1</td><td>2</td></tr>\n</table>\n";
#[test]
fn default_table_alignment_is_flush_left_with_no_indent_span_at_all() {
for src in [GFM, HTML] {
let (lines, _) = render(src, W, BlockAligns::default());
let (indent, _) = table_box(&lines);
assert_eq!(indent, 0, "既定は左寄せ: {src:?}");
let top = lines
.iter()
.find(|l| text(l).starts_with('┌'))
.expect("top rule");
assert!(
top.spans.iter().all(|s| !s.content.trim().is_empty()),
"左寄せでは空白だけの先頭スパンを足さない(スナップショット不変の条件): {:?}",
top.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
);
}
}
#[test]
fn table_align_offsets_the_whole_box_and_gfm_and_html_agree() {
for src in [GFM, HTML] {
let (base, _) = render(src, W, BlockAligns::default());
let (_, boxw) = table_box(&base);
for a in ALL {
let (lines, _) = render(src, W, aligns(a, BlockAlign::Center));
let (indent, w2) = table_box(&lines);
assert_eq!(w2, boxw, "寄せで箱の幅は変わらない");
assert_eq!(
indent as u16,
a.offset(W, boxw as u16),
"{a:?} の桁位置 (src={src:?})"
);
for l in lines.iter().filter(|l| {
let t = text(l);
let t = t.trim_start();
t.starts_with('┌')
|| t.starts_with('│')
|| t.starts_with('├')
|| t.starts_with('└')
}) {
assert_eq!(
indent_of(l),
indent,
"行ごとに桁がずれている: {:?}",
text(l)
);
}
}
}
}
#[test]
fn a_table_wider_than_the_pane_stays_flush_left_for_every_alignment() {
let wide = "| aaaaaaaaaa | bbbbbbbbbb | cccccccccc |\n| --- | --- | --- |\n| 1 | 2 | 3 |\n";
for a in ALL {
let (lines, _) = render(wide, 20, aligns(a, BlockAlign::Center));
let (indent, boxw) = table_box(&lines);
assert!(boxw >= 20, "この幅では箱がペインを埋める: {boxw}");
assert_eq!(indent, 0, "{a:?} でもはみ出さない(桁は負にならない)");
}
}
#[test]
fn a_right_aligned_table_keeps_its_cell_image_inside_the_border() {
let src = "|  | bbbb |\n| --- | --- |\n| 1 | 2 |\n";
for a in ALL {
let (lines, images) = render(src, W, aligns(a, BlockAlign::Center));
let (indent, boxw) = table_box(&lines);
let left_border = indent as u16;
let right_border = left_border + boxw as u16 - 1;
assert_eq!(images.len(), 1, "セル画像が1枚配置される: {a:?}");
let p = &images[0];
assert!(
p.col > left_border,
"{a:?}: 画像が左罫線の内側にない col={} 左罫線={left_border}",
p.col
);
assert!(
p.col + p.cols <= right_border,
"{a:?}: 画像が右罫線をはみ出す col={} cols={} 右罫線={right_border}",
p.col,
p.cols
);
}
}
#[test]
fn a_cell_image_moves_by_exactly_the_same_amount_as_its_box() {
let src = "|  | bbbb |\n| --- | --- |\n| 1 | 2 |\n";
let (base_lines, base_images) = render(src, W, BlockAligns::default());
let (_, boxw) = table_box(&base_lines);
for a in ALL {
let (lines, images) = render(src, W, aligns(a, BlockAlign::Center));
let (indent, _) = table_box(&lines);
assert_eq!(indent as u16, a.offset(W, boxw as u16));
assert_eq!(
images[0].col,
base_images[0].col + indent as u16,
"{a:?}: セル画像の col が箱のインデントと 1 桁でもずれてはいけない"
);
assert_eq!(images[0].line, base_images[0].line, "行は動かない");
}
}
#[test]
fn a_cell_image_ignores_md_image_align_entirely() {
let src = "|  | bbbb |\n| --- | --- |\n| 1 | 2 |\n";
let reference = render(src, W, aligns(BlockAlign::Left, BlockAlign::Center)).1;
for a in ALL {
let images = render(src, W, aligns(BlockAlign::Left, a)).1;
assert_eq!(
images[0].col, reference[0].col,
"md_image_align={a:?} はセル内画像を動かさない(セル自身の整列が支配する)"
);
}
}
#[test]
fn a_standalone_block_image_follows_md_image_align() {
for a in ALL {
let (lines, images) = render("\n", W, aligns(BlockAlign::Left, a));
assert_eq!(images.len(), 1);
assert_eq!(images[0].col, a.offset(W, 10), "{a:?} の桁位置");
let label = lines
.iter()
.find(|l| text(l).contains('🖼'))
.expect("プレースホルダのラベル行");
assert_eq!(
indent_of(label) as u16,
images[0].col,
"{a:?}: ラベル行の字下げが画像の桁と一致しない"
);
}
}
#[test]
fn default_image_alignment_is_the_historical_centering() {
let (_, images) = render("\n", W, BlockAligns::default());
assert_eq!(images[0].col, (W - 10) / 2, "既定は従来どおり中央");
}
#[test]
fn a_packed_badge_row_moves_as_one_unit() {
let src = " \n";
for a in ALL {
let (_, images) = render(src, W, aligns(BlockAlign::Left, a));
assert_eq!(images.len(), 2, "{a:?}: バッジ2枚");
let start = a.offset(W, 21);
assert_eq!(images[0].col, start, "{a:?}: 1枚目");
assert_eq!(images[1].col, start + 11, "{a:?}: 2枚目(1桁の隙間つき)");
}
}
#[test]
fn an_image_wider_than_the_pane_stays_flush_left_for_every_alignment() {
for a in ALL {
let (_, images) = render("\n", 8, aligns(BlockAlign::Left, a));
assert_eq!(images[0].col, 0, "{a:?}: 10桁の画像が幅8のペインに入らない");
}
}
#[test]
fn a_mermaid_diagram_its_caption_and_its_focus_frame_all_agree() {
let src = "```mermaid\nflowchart TD\nA-->B\n```\n";
for a in ALL {
let (lines, images) = render(src, W, aligns(BlockAlign::Left, a));
assert_eq!(images.len(), 1, "{a:?}: 図の placement");
let p = &images[0];
let expect = mermaid_diagram_col(a, W, 20);
assert_eq!(p.col, expect, "{a:?}: 図の桁");
let caption = lines
.iter()
.find(|l| text(l).contains("◇ mermaid"))
.expect("キャプション行");
let head_w = text(caption).trim_start().width() as u16;
assert_eq!(
indent_of(caption) as u16,
p.col.min(W.saturating_sub(head_w)),
"{a:?}: キャプションの字下げが図の桁(折返し回避の上限つき)と一致しない"
);
assert!(
indent_of(caption) as u16 + head_w <= W,
"{a:?}: キャプションが幅を越えて折り返す"
);
let bw = p.cols + 2;
let bx = mermaid_focus_border_x(a, W, bw);
assert!(
bx < p.col && bx + bw > p.col + p.cols,
"{a:?}: 枠が図に重なる bx={bx} bw={bw} col={} cols={}",
p.col,
p.cols
);
assert!(bx + bw <= W, "{a:?}: 枠がペインをはみ出す bx={bx} bw={bw}");
}
}
#[test]
fn a_wide_titled_mermaid_frame_still_clears_the_picture() {
for a in ALL {
let col = mermaid_diagram_col(a, W, 20);
for bw in [22u16, 30, 44, W] {
let bx = mermaid_focus_border_x(a, W, bw);
assert!(bx + bw <= W, "{a:?}/bw={bw}: 枠がペイン外へ");
assert!(
bx < col && bx + bw > col + 20,
"{a:?}/bw={bw}: 枠が図に重なる bx={bx} col={col}"
);
}
}
}
#[test]
fn a_table_nested_in_a_quote_aligns_inside_the_quote_not_the_page() {
let src = "> | a | bbbb |\n> | --- | --- |\n> | 1 | 2 |\n";
let (base, _) = render(src, W, BlockAligns::default());
let base_row = base
.iter()
.find(|l| text(l).contains('┌'))
.expect("引用内の表");
let base_text = text(base_row);
let boxw = base_text[base_text.find('┌').unwrap()..].width() as u16;
for a in ALL {
let (lines, _) = render(src, W, aligns(a, BlockAlign::Center));
let row = lines
.iter()
.find(|l| text(l).contains('┌'))
.expect("引用内の表");
let t = text(row);
assert!(t.starts_with('>'), "引用のバーが先頭に残っていない: {t:?}");
let indent = t.chars().skip(2).take_while(|c| *c == ' ').count() as u16;
assert_eq!(
indent,
a.offset(W - 2, boxw),
"{a:?}: 引用の内側の幅で寄せていない ({t:?})"
);
}
}
#[test]
fn a_table_nested_in_an_open_details_body_aligns_inside_that_body() {
let src = "<details open>\n<summary>s</summary>\n\n| a | bbbb |\n| --- | --- |\n| 1 | 2 |\n\n</details>\n";
let (base, _) = render(src, W, BlockAligns::default());
let base_row = base
.iter()
.find(|l| text(l).contains('┌'))
.expect("details 内の表");
let base_text = text(base_row);
let boxw = base_text[base_text.find('┌').unwrap()..].width() as u16;
let left_prefix = base_text.chars().take_while(|c| *c != '┌').count();
for a in ALL {
let (lines, _) = render(src, W, aligns(a, BlockAlign::Center));
let row = lines
.iter()
.find(|l| text(l).contains('┌'))
.expect("details 内の表");
let t = text(row);
let at = t.chars().take_while(|c| *c != '┌').count();
assert_eq!(
(at - left_prefix) as u16,
a.offset(W - 2, boxw),
"{a:?}: details の内側の幅で寄せていない ({t:?})"
);
}
}
#[test]
fn math_is_untouched_by_md_image_align() {
for a in ALL {
let (_, display) = render("$$x^2$$\n", W, aligns(BlockAlign::Left, a));
assert_eq!(display.len(), 1);
assert_eq!(
display[0].col,
(W - 10) / 2,
"{a:?}: display 数式は中央のまま"
);
let (_, inline) = render("text $x$ more\n", W, aligns(BlockAlign::Left, a));
assert_eq!(inline.len(), 1);
assert_eq!(inline[0].col, 0, "{a:?}: inline 数式は左のまま");
}
}
#[test]
fn cell_alignment_inside_a_table_is_untouched_by_md_table_align() {
let src = "| a |\n| :-: |\n| x |\n";
let cell_row = |a: BlockAlign| -> String {
let (lines, _) = render(src, W, aligns(a, BlockAlign::Center));
let l = lines
.iter()
.find(|l| text(l).contains('x'))
.expect("本文行")
.clone();
text(&l).trim_start().to_string()
};
let left = cell_row(BlockAlign::Left);
for a in ALL {
assert_eq!(
cell_row(a),
left,
"{a:?}: セル内の整列は箱の寄せに影響されない"
);
}
}
#[test]
fn center_reproduces_the_historical_formula_for_every_width() {
for width in 0u16..=140 {
for content in 0u16..=140 {
assert_eq!(
BlockAlign::Center.offset(width, content),
width.saturating_sub(content) / 2,
"offset({width},{content})"
);
assert_eq!(
mermaid_diagram_col(BlockAlign::Center, width, content),
width.saturating_sub(content) / 2,
"mermaid_diagram_col({width},{content})"
);
assert_eq!(
mermaid_focus_border_x(BlockAlign::Center, width, content),
width.saturating_sub(content) / 2,
"mermaid_focus_border_x({width},{content})"
);
}
}
}
#[test]
fn offsets_saturate_instead_of_wrapping() {
for a in ALL {
assert_eq!(a.offset(10, 40), 0, "{a:?}: 中身の方が広い");
assert_eq!(a.offset(0, 0), 0);
}
assert_eq!(BlockAlign::Right.offset(40, 10), 30);
assert_eq!(BlockAlign::Left.offset(40, 10), 0);
}
#[test]
fn from_config_is_permissive_and_defaults_are_the_historical_layout() {
for (s, want) in [
("left", BlockAlign::Left),
("center", BlockAlign::Center),
("right", BlockAlign::Right),
(" RIGHT ", BlockAlign::Right),
("Center", BlockAlign::Center),
] {
assert_eq!(BlockAlign::from_config(s, BlockAlign::Left), want, "{s:?}");
}
for s in ["", "centre", "middle", "justify", "0", "left "] {
let s = if s == "left " { "lleft" } else { s };
assert_eq!(
BlockAlign::from_config(s, BlockAlign::Right),
BlockAlign::Right,
"未知の値 {s:?} は既定へ倒れる"
);
}
assert_eq!(
BlockAligns::default(),
BlockAligns {
table: BlockAlign::Left,
image: BlockAlign::Center
},
"既定 = 従来の見え方"
);
}
}