use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span, Text};
use pulldown_cmark::Options as ParseOptions;
use tui_markdown::{Options, StyleSheet};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
const CODE_GUTTER_FG: Color = Color::Cyan;
const HEAD_FG: Color = Color::Cyan;
const TABLE_BORDER_FG: Color = Color::Rgb(90, 98, 120);
#[derive(Clone, Copy, Debug, Default)]
pub struct CodeStyle {
pub bg: Option<Color>,
pub label_bg: Option<Color>,
pub label_right: bool,
pub tab_width: usize,
pub wrap: bool,
}
#[derive(Clone, Copy, Debug, Default)]
struct KonomaStyles {
code_bg: Option<Color>,
}
impl StyleSheet for KonomaStyles {
fn heading(&self, level: u8) -> Style {
let base = Style::new().fg(HEAD_FG).add_modifier(Modifier::BOLD);
match level {
1 | 2 => base, 3 => base.add_modifier(Modifier::ITALIC),
_ => Style::new()
.fg(Color::Cyan)
.add_modifier(Modifier::DIM | Modifier::ITALIC),
}
}
fn code(&self) -> Style {
let s = Style::new().fg(Color::White);
match self.code_bg {
Some(bg) => s.bg(bg),
None => s,
}
}
fn link(&self) -> Style {
Style::new()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED)
}
fn blockquote(&self) -> Style {
Style::new().fg(Color::Green).add_modifier(Modifier::ITALIC)
}
fn heading_meta(&self) -> Style {
Style::new().add_modifier(Modifier::DIM)
}
fn metadata_block(&self) -> Style {
Style::new().fg(Color::LightYellow)
}
}
#[cfg(test)]
pub fn render_markdown(
src: &str,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
) -> Vec<Line<'static>> {
render_markdown_tasks(src, width, code, theme, icons, DEFAULT_TASK_STATES)
}
pub(crate) const DEFAULT_TASK_STATES: &[char] = &[' ', 'x'];
#[cfg(test)]
pub fn render_markdown_tasks(
src: &str,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
tasks: &[char],
) -> Vec<Line<'static>> {
set_details_open(Vec::new());
render_markdown_tasks_opts(
&SourceRun::parse(src.to_string()),
width,
code,
theme,
icons,
tasks,
true,
)
}
#[derive(Clone, Copy)]
struct MdRenderCtx<'a> {
width: u16,
code: CodeStyle,
theme: &'a str,
icons: bool,
tasks: &'a [char],
alerts: bool,
}
fn render_markdown_tasks_opts(
src: &SourceRun,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
tasks: &[char],
alerts: bool,
) -> Vec<Line<'static>> {
let ctx = MdRenderCtx {
width,
code,
theme,
icons,
tasks,
alerts,
};
let mut out = Vec::new();
for seg in split_segments(src) {
match seg {
Segment::Md(text) => {
if text.text().trim().is_empty() {
continue;
}
if alerts {
for ap in split_alerts(&text) {
match ap {
AlertPart::Text(t) => render_md_text(&mut out, &t, &ctx),
AlertPart::Alert { kind, title, body } => {
out.extend(render_alert(kind, &title, &body, &ctx))
}
}
}
} else {
render_md_text(&mut out, &text, &ctx);
}
}
Segment::Mermaid(code) => out.extend(render_mermaid_block(&code, width)),
}
}
out
}
fn render_md_text(out: &mut Vec<Line<'static>>, text: &SourceRun, ctx: &MdRenderCtx) {
for part in split_details(text) {
match part {
DetailsPart::Text(t) => render_md_text_inner(out, &t, ctx),
DetailsPart::Details {
open_attr,
summary,
body,
} => {
let open = next_details_open(open_attr);
out.extend(render_details(open, &summary, &body, true, ctx));
}
}
}
}
fn render_md_body_nested(out: &mut Vec<Line<'static>>, text: &SourceRun, ctx: &MdRenderCtx) {
if ctx.alerts {
for ap in split_alerts(text) {
match ap {
AlertPart::Text(t) => render_md_details_static(out, &t, ctx),
AlertPart::Alert { kind, title, body } => {
out.extend(render_alert(kind, &title, &body, ctx))
}
}
}
} else {
render_md_details_static(out, text, ctx);
}
}
fn render_md_details_static(out: &mut Vec<Line<'static>>, text: &SourceRun, ctx: &MdRenderCtx) {
for dp in split_details(text) {
match dp {
DetailsPart::Text(t2) => render_md_text_inner(out, &t2, ctx),
DetailsPart::Details {
open_attr,
summary,
body,
} => {
out.extend(render_details(open_attr, &summary, &body, false, ctx));
}
}
}
}
fn render_md_text_inner(out: &mut Vec<Line<'static>>, text: &SourceRun, ctx: &MdRenderCtx) {
let opts = Options::new(KonomaStyles {
code_bg: ctx.code.bg,
});
for part in split_tables(text) {
match part {
MdPart::Text(t) => {
if t.text().trim().is_empty() {
continue;
}
for hp in split_html_blocks(&t) {
match hp {
HtmlPart::Text(t2) => {
let t2 = t2.text();
if t2.trim().is_empty() {
continue;
}
render_text_block_safe(out, t2, &opts, ctx, 0);
}
HtmlPart::Html(h) => out.extend(render_html_block(&h)),
}
}
}
MdPart::Table(raw) => out.extend(render_table(&raw, ctx.width, ctx.icons)),
}
}
}
thread_local! {
static PANIC_SILENCED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
pub(crate) fn catch_silent<T>(f: impl FnOnce() -> T) -> Option<T> {
silence_panics(|| std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok())
}
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
}
fn render_md_segment(src: &str, opts: &Options<KonomaStyles>) -> Option<Vec<Line<'static>>> {
silence_panics(|| {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
into_static_lines(tui_markdown::from_str_with_options(src, opts))
}))
.ok()
})
}
#[derive(Clone, Debug, PartialEq)]
pub struct ImagePlacement {
pub url: String,
pub alt: String,
pub line: usize,
pub cols: u16,
pub rows: u16,
pub fence_ord: Option<usize>,
}
#[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 {
alt: String,
url: String,
},
Mermaid {
code: String,
},
Math {
latex: String,
display: bool,
},
}
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,
}
#[allow(clippy::too_many_arguments)] pub fn render_markdown_with_images(
src: &str,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
tasks: &[char],
slot_of: &dyn Fn(&str) -> ImageSlot,
mermaid_slot: &dyn Fn(&str) -> MermaidSlot,
mermaid_caption: &str,
alerts: bool,
math_slot: &dyn Fn(&str, bool) -> MathSlot,
math_on: bool,
) -> (Vec<Line<'static>>, Vec<ImagePlacement>) {
let mut out: Vec<Line<'static>> = Vec::new();
let mut placements: Vec<ImagePlacement> = Vec::new();
let fences_on = !matches!(mermaid_slot(""), MermaidSlot::Text);
let mut fence_ord = 0usize;
let parts: Vec<BlockPart> = if math_on {
split_block_parts(src, fences_on)
.into_iter()
.flat_map(|p| match p {
BlockPart::Text(t) => split_math(&t)
.into_iter()
.map(|mp| match mp {
MathPart::Text(s) => BlockPart::Text(s),
MathPart::Math { latex, display } => BlockPart::Math { latex, display },
})
.collect::<Vec<_>>(),
other => vec![other],
})
.collect()
} else {
split_block_parts(src, fences_on)
};
for part in parts {
match part {
BlockPart::Text(t) => out.extend(render_markdown_tasks_opts(
&t, width, code, theme, icons, tasks, alerts,
)),
BlockPart::Image { alt, url } => match slot_of(&url) {
ImageSlot::Inline { cols, rows } => {
placements.push(ImagePlacement {
url,
alt: alt.clone(),
line: out.len(),
cols,
rows,
fence_ord: None,
});
out.extend(image_placeholder_lines(cols, rows, &alt, width));
}
ImageSlot::Loading => out.extend(image_loading_line(&alt, &url, width)),
ImageSlot::Unavailable => out.extend(image_text_fallback(&alt, &url, width)),
},
BlockPart::Mermaid { code: fence } => {
let ord = fence_ord;
fence_ord += 1;
if fence.trim().is_empty() {
out.extend(render_mermaid_block(&fence, width));
continue;
}
match mermaid_slot(&fence) {
MermaidSlot::Image { cols, rows } => {
let url = mermaid_fence_url(&fence);
let mut ls = mermaid_placeholder_lines(cols, rows, width, mermaid_caption);
out.push(ls.remove(0));
placements.push(ImagePlacement {
url,
alt: "mermaid".into(),
line: out.len(),
cols,
rows,
fence_ord: Some(ord),
});
out.extend(ls);
}
MermaidSlot::Loading => {
out.extend(image_loading_line("mermaid", "diagram", width))
}
MermaidSlot::Text => out.extend(render_mermaid_block(&fence, width)),
}
}
BlockPart::Math { latex, display } => match math_slot(&latex, display) {
MathSlot::Image { cols, rows } => {
placements.push(ImagePlacement {
url: math_url(&latex, display),
alt: "math".into(),
line: out.len(),
cols,
rows,
fence_ord: None,
});
out.extend(math_placeholder_lines(cols, rows, width, display));
}
MathSlot::Loading => out.extend(image_loading_line("math", "equation", width)),
MathSlot::Raw => out.extend(math_raw_lines(&latex, display)),
},
}
}
(out, placements)
}
fn math_placeholder_lines(cols: u16, rows: u16, width: u16, display: bool) -> Vec<Line<'static>> {
let rows = rows.max(1);
let pad = if display {
(width.saturating_sub(cols) / 2) as usize
} else {
0
};
let indent = " ".repeat(pad);
let mut lines = Vec::with_capacity(rows as usize);
for _ in 0..rows {
lines.push(Line::from(indent.clone()));
}
lines
}
fn math_raw_lines(latex: &str, display: bool) -> Vec<Line<'static>> {
let text = if display {
format!("$$ {} $$", latex.trim())
} else {
format!("${}$", latex.trim())
};
vec![Line::from(Span::from(text).dim())]
}
fn mermaid_header_style() -> Style {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::ITALIC)
}
pub fn is_mermaid_header_span(span: &Span<'_>) -> bool {
span.style == mermaid_header_style() && span.content.starts_with("◇ mermaid")
}
fn mermaid_placeholder_lines(
cols: u16,
rows: u16,
width: u16,
caption: &str,
) -> Vec<Line<'static>> {
let rows = rows.max(1);
let pad = (width.saturating_sub(cols) / 2) as usize;
let indent = " ".repeat(pad);
let mut lines = Vec::with_capacity(rows as usize + 2);
lines.push(Line::from(vec![
Span::raw(indent),
Span::styled(format!("◇ mermaid — {caption}"), mermaid_header_style()),
]));
for _ in 0..rows {
lines.push(Line::from(String::new()));
}
lines.push(Line::from(String::new()));
lines
}
pub fn is_remote_image_url(url: &str) -> bool {
let lower = url.trim().to_ascii_lowercase();
lower.starts_with("http://") || lower.starts_with("https://")
}
pub fn collect_remote_image_urls(src: &str) -> Vec<String> {
let mut urls = Vec::new();
for part in split_block_images(src) {
if let BlockPart::Image { url, .. } = part {
if is_remote_image_url(&url) {
urls.push(url);
}
}
}
urls
}
fn split_block_images(src: &str) -> Vec<BlockPart> {
split_block_parts(src, false)
}
fn split_block_parts(src: &str, mermaid_fences: bool) -> Vec<BlockPart> {
let doc = splitter_code_mask(&src.lines().collect::<Vec<_>>());
split_block_parts_masked(src, &doc, mermaid_fences)
}
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 { alt, 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(cols: u16, rows: u16, alt: &str, width: u16) -> Vec<Line<'static>> {
let rows = rows.max(1);
let pad = (width.saturating_sub(cols) / 2) as usize;
let indent = " ".repeat(pad);
let alt = alt.trim();
let label = if alt.is_empty() {
"🖼 image".to_string()
} else {
format!("🖼 {alt}")
};
let label = truncate_width(&label, cols as usize);
let mut lines = Vec::with_capacity(rows as usize);
lines.push(Line::from(format!("{indent}{label}")).dim());
for _ in 1..rows {
lines.push(Line::from(String::new()));
}
lines
}
fn image_text_fallback(alt: &str, url: &str, width: u16) -> Vec<Line<'static>> {
let alt = alt.trim();
let s = if alt.is_empty() {
format!("🖼 {url}")
} else {
format!("🖼 {alt} — {url}")
};
vec![Line::from(truncate_width(&s, width as usize)).dim()]
}
fn image_loading_line(alt: &str, url: &str, width: u16) -> Vec<Line<'static>> {
let alt = alt.trim();
let what = if alt.is_empty() { url } else { alt };
let s = format!("🖼 {what} — loading…");
vec![Line::from(truncate_width(&s, width as usize)).dim()]
}
fn truncate_width(s: &str, max: usize) -> String {
if s.width() <= max {
return s.to_string();
}
let budget = max.saturating_sub(1);
let mut out = String::new();
let mut w = 0usize;
for c in s.chars() {
let cw = c.width().unwrap_or(0);
if w + cw > budget {
break;
}
out.push(c);
w += cw;
}
out.push('…');
out
}
fn decorate_md_lines(
lines: Vec<Line<'static>>,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
tasks: &[char],
) -> Vec<Line<'static>> {
let lines = decorate_code_blocks(lines, width, code, theme);
let lines = decorate_headings(lines, width);
decorate_extras(lines, width, icons, tasks)
}
fn decorate_extras(
lines: Vec<Line<'static>>,
width: u16,
icons: bool,
tasks: &[char],
) -> Vec<Line<'static>> {
lines
.into_iter()
.map(|l| {
let joined: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
let t = joined.trim();
if t == "---" || t == "***" || t == "___" {
return Line::from(Span::styled(
"─".repeat(width as usize),
Style::new().fg(TABLE_BORDER_FG),
));
}
replace_task_checkbox(l, &joined, icons, tasks)
})
.collect()
}
fn replace_task_checkbox(
l: Line<'static>,
joined: &str,
icons: bool,
tasks: &[char],
) -> Line<'static> {
let Some((state, _)) = task_prefix_state(joined.trim_start(), tasks) else {
return l;
};
let pat = format!("[{state}]");
let Some(pos) = joined.find(&pat) else {
return l;
};
let trail_space = joined[pos + pat.len()..].starts_with(' ');
let end = pos + pat.len() + usize::from(trail_space);
let (style, alignment) = (l.style, l.alignment);
let mut out: Vec<Span<'static>> = Vec::new();
let mut off = 0usize;
let mut inserted = false;
for sp in l.spans {
let s = sp.content.as_ref();
let (a, b) = (off, off + s.len());
off = b;
if b <= pos || a >= end {
out.push(sp); continue;
}
if a < pos {
out.push(Span::styled(s[..pos - a].to_string(), sp.style));
}
if !inserted {
let mut disp = task_marker_display(state, icons);
if trail_space {
disp.push(' ');
}
out.push(Span::styled(disp, task_marker_style()));
inserted = true;
}
if b > end {
out.push(Span::styled(s[end - a..].to_string(), sp.style));
}
}
let mut nl = Line::from(out).style(style);
nl.alignment = alignment;
nl
}
fn task_prefix_state(t: &str, tasks: &[char]) -> Option<(char, 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
}
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..]
}
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' '
}
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'))
}
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
}
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 == '-'))
}
struct ListGuard {
in_list: bool,
}
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;
}
}
}
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 tui_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
}
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, tui_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);
}
}
_ => {}
}
}
}
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);
}
}
}
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(_) => {} }
}
}
}
pub(crate) fn code_block_source_locs(src: &str, details_open: &[bool]) -> Vec<String> {
code_block_source_locs_inner(src, details_open, false)
}
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,
pub state: char,
}
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);
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 + ws + off,
state,
});
}
prev_not_paragraph = false;
idx += 1;
}
}
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
}
fn is_code_block_line(line: &Line<'_>) -> bool {
line.style.fg == Some(Color::White)
}
fn flush_code_run(
out: &mut Vec<Line<'static>>,
run: &mut Vec<Line<'static>>,
w: usize,
code_bg: Option<Color>,
theme: &str,
code: CodeStyle,
) {
if run.is_empty() {
return;
}
let lines = std::mem::take(run);
let opening = lines[0].to_string();
let opening_trimmed = opening.trim_start();
let closing_ok = lines.last().is_some_and(|l| {
let text = l.to_string();
is_closing_fence(text.trim_start())
});
if lines.len() < 2 || !opening_trimmed.starts_with("```") || !closing_ok {
out.extend(lines);
return;
}
let lang = opening_trimmed.trim_matches('`').trim().to_string();
let label = if lang.is_empty() {
"code"
} else {
lang.as_str()
};
out.push(code_header(label, w, code));
let body: Vec<String> = lines[1..lines.len() - 1]
.iter()
.map(|l| l.to_string())
.collect();
out.extend(highlight_body(
&body,
&lang,
w,
code_bg,
theme,
code.tab_width,
code.wrap,
));
out.push(pad_to_width(vec![gutter_span(code_bg)], w, code_bg));
}
fn decorate_code_blocks(
lines: Vec<Line<'static>>,
width: u16,
code: CodeStyle,
theme: &str,
) -> Vec<Line<'static>> {
let w = width as usize;
let code_bg = code.bg;
let mut out = Vec::with_capacity(lines.len());
let mut run: Vec<Line<'static>> = Vec::new();
for line in lines {
if is_code_block_line(&line) {
run.push(line);
continue;
}
flush_code_run(&mut out, &mut run, w, code_bg, theme, code);
out.push(line);
}
flush_code_run(&mut out, &mut run, w, code_bg, theme, code);
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
}
fn render_text_block_safe(
out: &mut Vec<Line<'static>>,
src: &str,
opts: &Options<KonomaStyles>,
ctx: &MdRenderCtx,
depth: u8,
) {
if src.trim().is_empty() {
return;
}
if let Some(lines) = render_md_segment(src, opts) {
out.extend(decorate_md_lines(
lines, ctx.width, ctx.code, ctx.theme, ctx.icons, ctx.tasks,
));
return;
}
if depth >= 8 {
out.extend(src.lines().map(|l| Line::from(l.to_string())));
return;
}
match split_block_for_retry(src) {
Some((a, b)) => {
render_text_block_safe(out, a, opts, ctx, depth + 1);
render_text_block_safe(out, b, opts, ctx, depth + 1);
}
None => out.extend(src.lines().map(|l| Line::from(l.to_string()))),
}
}
fn split_block_for_retry(src: &str) -> Option<(&str, &str)> {
let mut blanks: Vec<usize> = Vec::new(); let mut newlines: Vec<usize> = Vec::new(); let mut fence: Option<Fence> = None;
let mut off = 0usize;
for line in src.split_inclusive('\n') {
let bare = line.strip_suffix('\n').unwrap_or(line);
if let Some(f) = fence {
let closing = parse_fence(bare)
.map(|(nf, info)| nf.ch == f.ch && nf.len >= f.len && info.is_empty())
.unwrap_or(false);
if closing {
fence = None;
}
} else if let Some((f, _info)) = parse_fence(bare) {
fence = Some(f);
}
let end = off + line.len();
if fence.is_none() && line.ends_with('\n') {
if line.trim().is_empty() {
blanks.push(off);
}
newlines.push(end);
}
off = end;
}
let mid = src.len() / 2;
let pick = |cands: &[usize]| -> Option<usize> {
cands
.iter()
.copied()
.filter(|&i| i > 0 && i < src.len())
.min_by_key(|&i| i.abs_diff(mid))
};
if let Some(i) = pick(&blanks) {
return Some((&src[..i], &src[i..]));
}
let cut = pick(&newlines)?;
if cut == 0 || cut >= src.len() {
return None;
}
Some((&src[..cut], &src[cut..]))
}
const GUTTER_COLS: usize = 2;
fn wrap_spans_by_width(spans: Vec<Span<'static>>, maxw: usize) -> Vec<Vec<Span<'static>>> {
use unicode_width::UnicodeWidthChar;
let mut rows: Vec<Vec<Span<'static>>> = Vec::new();
let mut cur: Vec<Span<'static>> = Vec::new();
let mut used = 0usize;
for sp in spans {
let mut buf = String::new();
for ch in sp.content.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + cw > maxw && used > 0 {
if !buf.is_empty() {
cur.push(Span::styled(std::mem::take(&mut buf), sp.style));
}
rows.push(std::mem::take(&mut cur));
used = 0;
}
buf.push(ch);
used += cw;
}
if !buf.is_empty() {
cur.push(Span::styled(buf, sp.style));
}
}
rows.push(cur);
rows
}
fn decorate_headings(lines: Vec<Line<'static>>, width: u16) -> Vec<Line<'static>> {
let w = width as usize;
let mut out = Vec::with_capacity(lines.len());
for line in lines {
if let Some(level) = heading_level(&line) {
let style = line.style;
let mut spans = line.spans;
spans.remove(0); out.push(Line::from(spans).style(style));
if level <= 2 {
let ch = if level == 1 { "━" } else { "─" };
out.push(Line::from(Span::styled(
ch.repeat(w),
Style::new().fg(HEAD_FG).add_modifier(Modifier::DIM),
)));
}
} else {
out.push(line);
}
}
out
}
pub(crate) fn heading_text(line: &Line<'_>) -> Option<String> {
if line.style.fg != Some(HEAD_FG) {
return None;
}
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
if text.starts_with('▎') {
return None; }
let t = text.trim();
let t = t.strip_prefix('▌').map(str::trim_start).unwrap_or(t);
if t.is_empty() || t.chars().all(|c| c == '━' || c == '─') {
return None;
}
Some(t.to_string())
}
pub(crate) fn heading_level_hint(line: &Line<'_>, next: Option<&Line<'_>>) -> u8 {
let m = line.style.add_modifier;
if m.contains(Modifier::DIM) {
return 4;
}
if m.contains(Modifier::ITALIC) {
return 3;
}
let rule: String = next
.map(|n| n.spans.iter().map(|s| s.content.as_ref()).collect())
.unwrap_or_default();
if rule.starts_with('━') {
1
} else {
2
}
}
fn heading_level(line: &Line) -> Option<u8> {
let content = line.spans.first()?.content.as_ref();
let hashes = content.strip_suffix(' ')?;
if !hashes.is_empty() && hashes.len() <= 6 && hashes.bytes().all(|b| b == b'#') {
Some(hashes.len() as u8)
} else {
None
}
}
fn gutter_span(code_bg: Option<Color>) -> Span<'static> {
let st = Style::new().fg(CODE_GUTTER_FG);
let st = match code_bg {
Some(bg) => st.bg(bg),
None => st,
};
Span::styled("▎ ", st)
}
fn code_header(label: &str, w: usize, code: CodeStyle) -> Line<'static> {
let code_bg = code.bg;
let gutter = {
let st = code_header_marker_style();
let st = match code_bg {
Some(bg) => st.bg(bg),
None => st,
};
Span::styled("▎ ", st)
};
let badge_text = format!(" {label} "); let badge_style = match code.label_bg {
Some(bg) => Style::new()
.fg(Color::White)
.bg(bg)
.add_modifier(Modifier::BOLD),
None => Style::new()
.fg(Color::Gray)
.add_modifier(Modifier::ITALIC | Modifier::DIM),
};
let gutter_w = gutter.width();
let badge_w = UnicodeWidthStr::width(badge_text.as_str());
let badge = Span::styled(badge_text, badge_style);
let fill_style = code_bg.map(|bg| Style::new().bg(bg)).unwrap_or_default();
let mut spans = vec![gutter];
if code.label_right && w > gutter_w + badge_w {
spans.push(Span::styled(" ".repeat(w - gutter_w - badge_w), fill_style));
spans.push(badge);
} else {
let used = gutter_w + badge_w;
spans.push(badge);
if w > used {
spans.push(Span::styled(" ".repeat(w - used), fill_style));
}
}
let line = Line::from(spans);
match code_bg {
Some(bg) => line.style(Style::new().bg(bg)),
None => line,
}
}
fn pad_to_width(mut spans: Vec<Span<'static>>, w: usize, code_bg: Option<Color>) -> Line<'static> {
let Some(bg) = code_bg else {
return Line::from(spans);
};
let used: usize = spans.iter().map(|s| s.width()).sum();
if used < w {
spans.push(Span::styled(" ".repeat(w - used), Style::new().bg(bg)));
}
Line::from(spans).style(Style::new().bg(bg))
}
fn is_closing_fence(trimmed: &str) -> bool {
let t = trimmed.trim_end();
t.len() >= 3 && t.bytes().all(|b| b == b'`')
}
pub fn render_mermaid_file(src: &str, width: u16) -> Vec<Line<'static>> {
render_mermaid_block(src, width)
}
fn render_mermaid_block(code: &str, width: u16) -> Vec<Line<'static>> {
let max_width = if width == 0 {
None
} else {
Some(width as usize)
};
match render_mermaid_safe(code.trim_end_matches('\n'), max_width) {
Ok(rendered) => rendered
.lines()
.map(|l| Line::from(l.to_string()))
.collect(),
Err(note) => fallback_raw(code, ¬e),
}
}
fn render_mermaid_safe(code: &str, max_width: Option<usize>) -> Result<String, String> {
let caught = silence_panics(|| {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
mermaid_text::render_with_width(code, max_width)
}))
});
match caught {
Ok(Ok(s)) => Ok(s),
Ok(Err(e)) => Err(format!("cannot render mermaid: {e}")),
Err(_) => Err(
"cannot render mermaid (internal error: this diagram/char may be unsupported)"
.to_string(),
),
}
}
pub fn mermaid_to_svg(code: &str, theme: &str) -> Option<String> {
use mermaid_rs_renderer::{RenderOptions, Theme};
let modern = Theme::modern();
let mut t = match theme {
"light" | "modern" => Theme::modern(),
"classic" | "mermaid" => Theme::mermaid_default(),
"forest" => Theme::forest(),
"neutral" => Theme::neutral(),
_ => Theme::dark(), };
t.background = "none".to_string();
t.font_family = modern.font_family.clone();
t.font_size = modern.font_size;
let opts = RenderOptions {
theme: t,
..RenderOptions::default()
};
let caught = silence_panics(|| {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
mermaid_rs_renderer::render_with_options(code.trim_end_matches('\n'), opts)
}))
});
match caught {
Ok(Ok(svg)) => Some(svg),
_ => None,
}
}
pub fn warm_mermaid() {
let _ = mermaid_to_svg("graph LR\nA-->B", "dark");
}
pub fn mermaid_fence_url(code: &str) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &b in code.as_bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("mermaid-fence://{h:016x}")
}
pub fn is_mermaid_fence_url(url: &str) -> bool {
url.starts_with("mermaid-fence://")
}
pub fn collect_mermaid_fences(src: &str) -> Vec<String> {
split_block_parts(src, true)
.into_iter()
.filter_map(|p| match p {
BlockPart::Mermaid { code } => Some(code),
_ => None,
})
.collect()
}
pub fn math_url(latex: &str, display: bool) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &b in latex.as_bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("math://{}{h:016x}", if display { "d" } else { "i" })
}
pub fn is_math_url(url: &str) -> bool {
url.starts_with("math://")
}
pub fn is_synthetic_md_url(url: &str) -> bool {
is_mermaid_fence_url(url) || is_math_url(url)
}
pub fn collect_math_exprs(src: &str) -> Vec<(String, bool)> {
split_block_parts(src, false)
.into_iter()
.flat_map(|p| match p {
BlockPart::Text(t) => split_math(&t)
.into_iter()
.filter_map(|mp| match mp {
MathPart::Math { latex, display } => Some((latex, display)),
MathPart::Text(_) => None,
})
.collect::<Vec<_>>(),
_ => Vec::new(),
})
.collect()
}
fn utf8_len(b: u8) -> usize {
if b < 0x80 {
1
} else if b >> 5 == 0b110 {
2
} else if b >> 4 == 0b1110 {
3
} else {
4
}
}
fn flush_math(
out: &mut Vec<MathPart>,
buf: &mut String,
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
}
fn into_static_lines(text: Text) -> Vec<Line<'static>> {
text.lines.into_iter().map(line_into_static).collect()
}
fn line_into_static(line: Line) -> Line<'static> {
let spans: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|s| Span::styled(s.content.into_owned(), s.style))
.collect();
let mut out = Line::from(spans).style(line.style);
if let Some(alignment) = line.alignment {
out = out.alignment(alignment);
}
out
}
#[derive(Debug, PartialEq)]
enum Segment {
Md(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"))
}
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
}
enum MdPart {
Text(SourceRun),
Table(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, tui_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
}
enum HtmlPart {
Text(SourceRun),
Html(String),
}
fn is_html_block_start(line: &str) -> bool {
let t = line.trim_start();
if t.starts_with("<!--") {
return true;
}
let Some(rest) = t.strip_prefix('<') else {
return false;
};
let rest = rest.strip_prefix('/').unwrap_or(rest);
let name_len = rest
.char_indices()
.take_while(|(i, c)| {
if *i == 0 {
c.is_ascii_alphabetic()
} else {
c.is_ascii_alphanumeric() || *c == '-'
}
})
.count();
if name_len == 0 {
return false;
}
matches!(
rest[name_len..].chars().next(),
None | Some(' ') | Some('\t') | Some('>') | Some('/')
)
}
fn split_html_blocks(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
}
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)
}
fn render_html_block(raw: &str) -> Vec<Line<'static>> {
let mut text = String::new();
let mut rest = raw;
while let Some(pos) = rest.find('<') {
text.push_str(&rest[..pos]);
let after = &rest[pos..];
if let Some(r) = after.strip_prefix("<!--") {
match r.find("-->") {
Some(e) => rest = &r[e + 3..],
None => rest = "",
}
} else {
match after.find('>') {
Some(e) => rest = &after[e + 1..],
None => rest = "",
}
}
}
text.push_str(rest);
let text = text
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace(" ", " ");
let mut out: Vec<Line<'static>> = text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(|l| Line::from(Span::raw(l.to_string())))
.collect();
if !out.is_empty() {
out.push(Line::from(""));
}
out
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum AlertKind {
Note,
Tip,
Important,
Warning,
Caution,
}
impl AlertKind {
fn parse(s: &str) -> Option<AlertKind> {
match s.trim().to_ascii_lowercase().as_str() {
"note" | "info" => Some(AlertKind::Note),
"tip" | "hint" => Some(AlertKind::Tip),
"important" => Some(AlertKind::Important),
"warning" | "attention" => Some(AlertKind::Warning),
"caution" | "danger" | "error" => Some(AlertKind::Caution),
_ => None,
}
}
fn label(self) -> &'static str {
match self {
AlertKind::Note => "Note",
AlertKind::Tip => "Tip",
AlertKind::Important => "Important",
AlertKind::Warning => "Warning",
AlertKind::Caution => "Caution",
}
}
fn color(self) -> Color {
match self {
AlertKind::Note => Color::Blue,
AlertKind::Tip => Color::Green,
AlertKind::Important => Color::Magenta,
AlertKind::Warning => Color::Yellow,
AlertKind::Caution => Color::Red,
}
}
fn icon(self) -> char {
match self {
AlertKind::Note => '\u{f05a}', AlertKind::Tip => '\u{f0eb}', AlertKind::Important => '\u{f0a1}', AlertKind::Warning => '\u{f071}', AlertKind::Caution => '\u{f06a}', }
}
}
enum AlertPart {
Text(SourceRun),
Alert {
kind: AlertKind,
title: String,
body: String,
},
}
fn parse_alert_header(line: &str) -> Option<(AlertKind, String)> {
let rest = line.trim_start().strip_prefix('>')?.trim_start();
let rest = rest.strip_prefix("[!")?;
let close = rest.find(']')?;
let kind = AlertKind::parse(&rest[..close])?;
Some((kind, rest[close + 1..].trim().to_string()))
}
fn is_blockquote_line(line: &str) -> bool {
line.trim_start().starts_with('>')
}
fn strip_blockquote(line: &str) -> String {
let l = line.trim_start();
let l = l.strip_prefix('>').unwrap_or(l);
l.strip_prefix(' ').unwrap_or(l).to_string()
}
fn split_alerts(run: &SourceRun) -> Vec<AlertPart> {
let mut parts = Vec::new();
let mut text = String::new();
let mut mask: Vec<bool> = Vec::new();
let lines: Vec<&str> = run.lines();
let in_code = run.code();
let in_details = details_mask(&lines, in_code);
let mut i = 0;
while i < lines.len() {
let header = if in_code[i] || in_details[i] {
None
} else {
parse_alert_header(lines[i])
};
if let Some((kind, title)) = header {
if !text.is_empty() {
parts.push(AlertPart::Text(SourceRun::new(
std::mem::take(&mut text),
std::mem::take(&mut mask),
)));
}
i += 1;
let mut body = String::new();
while i < lines.len() && is_blockquote_line(lines[i]) {
body.push_str(&strip_blockquote(lines[i]));
body.push('\n');
i += 1;
}
parts.push(AlertPart::Alert { kind, title, body });
} else {
text.push_str(lines[i]);
text.push('\n');
mask.push(in_code[i]);
i += 1;
}
}
if !text.is_empty() {
parts.push(AlertPart::Text(SourceRun::new(text, mask)));
}
parts
}
fn render_alert(kind: AlertKind, title: &str, body: &str, ctx: &MdRenderCtx) -> Vec<Line<'static>> {
let color = kind.color();
let bar = || Span::styled("▌ ".to_string(), Style::new().fg(color));
let mut out = Vec::new();
let mut header = vec![bar()];
if ctx.icons {
header.push(Span::styled(
format!("{} ", kind.icon()),
Style::new().fg(color),
));
}
let label = if title.is_empty() {
kind.label().to_string()
} else {
format!("{} — {}", kind.label(), title)
};
header.push(Span::styled(
label,
Style::new().fg(color).add_modifier(Modifier::BOLD),
));
out.push(Line::from(header));
let inner_ctx = MdRenderCtx {
width: ctx.width.saturating_sub(2),
..*ctx
};
let mut body_lines = Vec::new();
render_md_body_nested(
&mut body_lines,
&SourceRun::parse(body.to_string()),
&inner_ctx,
);
for bl in body_lines {
let style = bl.style;
let mut spans = vec![bar()];
spans.extend(bl.spans);
out.push(Line::from(spans).style(style));
}
out
}
thread_local! {
static DETAILS: std::cell::RefCell<(usize, Vec<bool>)> =
const { std::cell::RefCell::new((0, Vec::new())) };
}
pub fn set_details_open(open: Vec<bool>) {
DETAILS.with(|d| *d.borrow_mut() = (0, open));
}
pub fn current_details_states() -> Vec<bool> {
DETAILS.with(|d| d.borrow().1.clone())
}
fn next_details_open(open_attr: bool) -> bool {
DETAILS.with(|d| {
let mut d = d.borrow_mut();
let ord = d.0;
let v = d.1.get(ord).copied().unwrap_or(open_attr);
d.0 = ord + 1;
v
})
}
enum DetailsPart {
Text(SourceRun),
Details {
open_attr: bool,
summary: String,
body: String,
},
}
fn details_open_tag(line: &str) -> Option<bool> {
let t = line.trim();
let lower = t.to_ascii_lowercase();
let rest = lower.strip_prefix("<details")?;
if !(rest.is_empty() || rest.starts_with('>') || rest.starts_with(' ')) {
return None; }
Some(rest.contains("open"))
}
fn is_details_close(line: &str) -> bool {
line.trim().eq_ignore_ascii_case("</details>")
}
fn 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 details_mask(lines: &[&str], in_code: &[bool]) -> Vec<bool> {
let mut mask = vec![false; lines.len()];
let mut i = 0;
while i < lines.len() {
if in_code[i] {
i += 1;
continue;
}
if details_open_tag(lines[i]).is_some() {
let close = details_block_close(lines, i);
let end = close.unwrap_or(lines.len() - 1);
mask[i..=end].fill(true);
i = close.map_or(lines.len(), |c| c + 1);
continue;
}
i += 1;
}
mask
}
fn extract_summary_body(inner: &str) -> (String, String) {
let lower = inner.to_ascii_lowercase();
if let (Some(s), Some(e)) = (lower.find("<summary"), lower.find("</summary>")) {
if s < e {
if let Some(gt) = inner[s..e].find('>') {
let sum = strip_inline_html_tags(inner[s + gt + 1..e].trim());
let body = trim_blank_lines(&inner[e + "</summary>".len()..]);
return (sum, body);
}
}
}
(String::new(), inner.trim().to_string())
}
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 render_details(
open: bool,
summary: &str,
body: &str,
interactive: bool,
ctx: &MdRenderCtx,
) -> Vec<Line<'static>> {
let mut out = Vec::new();
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)
};
out.push(Line::from(vec![
Span::styled(format!("{arrow} "), marker_style),
Span::styled(label.to_string(), Style::new().add_modifier(Modifier::BOLD)),
]));
if open && !body.trim().is_empty() {
let bar = || Span::styled("▏ ".to_string(), Style::new().fg(TABLE_BORDER_FG));
let inner_ctx = MdRenderCtx {
width: ctx.width.saturating_sub(2),
..*ctx
};
let mut body_lines = Vec::new();
render_md_body_nested(
&mut body_lines,
&SourceRun::parse(body.to_string()),
&inner_ctx,
);
for bl in body_lines {
let style = bl.style;
let mut spans = vec![bar()];
spans.extend(bl.spans);
out.push(Line::from(spans).style(style));
}
}
out
}
pub fn collect_details_open(src: &str) -> Vec<bool> {
split_details(&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 = tui_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.len() - l.trim_start().len())
.min()
.unwrap_or(0);
let mut text = first;
for l in rest {
text.push('\n');
if l.trim().is_empty() {
continue; }
text.push_str(&l[indent.min(l.len() - l.trim_start().len())..]);
}
Some(text)
}
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()
}
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 parse_table_row(line: &str) -> Vec<String> {
let t = line.trim();
let t = t.strip_prefix('|').unwrap_or(t);
let t = if t.ends_with('|') && !t.ends_with("\\|") {
&t[..t.len() - 1]
} else {
t
};
let mut cells = Vec::new();
let mut cur = String::new();
let mut chars = t.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\\' if chars.peek() == Some(&'|') => {
cur.push('|');
chars.next();
}
'|' => cells.push(std::mem::take(&mut cur)),
_ => cur.push(c),
}
}
cells.push(cur);
cells.into_iter().map(|c| c.trim().to_string()).collect()
}
#[derive(Clone, Copy, PartialEq)]
enum ColAlign {
Left,
Center,
Right,
}
fn parse_table_aligns(line: &str) -> Vec<ColAlign> {
parse_table_row(line)
.iter()
.map(|c| {
let l = c.starts_with(':');
let r = c.ends_with(':');
match (l, r) {
(true, true) => ColAlign::Center,
(false, true) => ColAlign::Right,
_ => ColAlign::Left,
}
})
.collect()
}
pub fn link_label_style() -> Style {
Style::new()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED)
}
pub fn hidden_link_target_style() -> Style {
link_label_style().add_modifier(Modifier::HIDDEN)
}
pub fn is_hidden_link_target(span: &Span<'_>) -> bool {
span.style.add_modifier.contains(Modifier::HIDDEN)
&& span.style.add_modifier.contains(Modifier::UNDERLINED)
&& span.style.fg == Some(Color::Blue)
}
#[derive(Clone)]
enum CellSeg {
Text { text: String, style: Style },
Link { label: String, url: String },
}
impl CellSeg {
fn plain(text: String) -> CellSeg {
CellSeg::Text {
text,
style: Style::new(),
}
}
}
fn seg_width(seg: &CellSeg) -> usize {
match seg {
CellSeg::Text { text, .. } => UnicodeWidthStr::width(text.as_str()),
CellSeg::Link { label, .. } => UnicodeWidthStr::width(label.as_str()),
}
}
fn try_inline_styled(rest: &str) -> Option<(usize, CellSeg)> {
const MARKERS: &[&str] = &["***", "**", "*", "~~", "`"];
for open in MARKERS {
let Some(r) = rest.strip_prefix(open) else {
continue;
};
let Some(end) = r.find(open) else {
continue;
};
if end == 0 {
continue; }
let inner = &r[..end];
if *open != "`"
&& (inner.starts_with(char::is_whitespace) || inner.ends_with(char::is_whitespace))
{
continue; }
let style = match *open {
"***" => Style::new().add_modifier(Modifier::BOLD | Modifier::ITALIC),
"**" => Style::new().add_modifier(Modifier::BOLD),
"*" => Style::new().add_modifier(Modifier::ITALIC),
"~~" => Style::new().add_modifier(Modifier::CROSSED_OUT),
"`" => Style::new().fg(Color::White),
_ => unreachable!(),
};
return Some((
open.len() + end + open.len(),
CellSeg::Text {
text: inner.to_string(),
style,
},
));
}
None
}
fn segs_width(segs: &[CellSeg]) -> usize {
segs.iter().map(seg_width).sum()
}
fn parse_cell_segments(cell: &str) -> Vec<CellSeg> {
let mut out = Vec::new();
let mut text = String::new();
let mut i = 0;
while i < cell.len() {
let rest = &cell[i..];
if let Some((consumed, seg)) = try_inline_styled(rest) {
if !text.is_empty() {
out.push(CellSeg::plain(std::mem::take(&mut text)));
}
out.push(seg);
i += consumed;
continue;
}
if rest.starts_with('[') && !text.ends_with('!') {
if let Some(close) = rest.find(']') {
let after = &rest[close + 1..];
if let Some(url_rest) = after.strip_prefix('(') {
if let Some(par) = url_rest.find(')') {
let label = &rest[1..close];
let url = strip_link_destination(&url_rest[..par]);
let url = url.as_str();
if !label.is_empty() && !url.is_empty() {
if !text.is_empty() {
out.push(CellSeg::plain(std::mem::take(&mut text)));
}
out.push(CellSeg::Link {
label: label.to_string(),
url: url.to_string(),
});
i += close + 2 + par + 1;
continue;
}
}
}
}
}
let ch = rest.chars().next().expect("non-empty rest");
text.push(ch);
i += ch.len_utf8();
}
if !text.is_empty() {
out.push(CellSeg::plain(text));
}
out
}
fn strip_link_destination(dest: &str) -> String {
let d = dest.trim();
if let Some(inner) = d.strip_prefix('<').and_then(|x| x.strip_suffix('>')) {
return inner.trim().to_string();
}
if let Some(sp) = d.find(char::is_whitespace) {
let (u, rest) = d.split_at(sp);
let rest = rest.trim();
let quoted = rest.len() >= 2
&& ((rest.starts_with('"') && rest.ends_with('"'))
|| (rest.starts_with('\'') && rest.ends_with('\'')));
if quoted {
return u.to_string();
}
}
d.to_string()
}
fn truncate_to_width(s: &str, w: usize) -> String {
let mut out = String::new();
let mut used = 0usize;
for ch in s.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(1);
if used + cw > w {
break;
}
out.push(ch);
used += cw;
}
out
}
fn wrap_segments(segs: &[CellSeg], w: usize) -> Vec<Vec<CellSeg>> {
if w == 0 || segs_width(segs) <= w {
return vec![segs.to_vec()];
}
let mut lines: Vec<Vec<CellSeg>> = Vec::new();
let mut cur: Vec<CellSeg> = Vec::new();
let mut cur_w = 0usize;
for seg in segs {
match seg {
CellSeg::Text { text, style } => {
let mut buf = String::new();
for ch in text.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(1);
if cur_w + cw > w && cur_w > 0 {
if !buf.is_empty() {
cur.push(CellSeg::Text {
text: std::mem::take(&mut buf),
style: *style,
});
}
lines.push(std::mem::take(&mut cur));
cur_w = 0;
}
buf.push(ch);
cur_w += cw;
}
if !buf.is_empty() {
cur.push(CellSeg::Text {
text: buf,
style: *style,
});
}
}
CellSeg::Link { label, url } => {
let lw = UnicodeWidthStr::width(label.as_str());
if cur_w + lw > w && cur_w > 0 {
lines.push(std::mem::take(&mut cur));
cur_w = 0;
}
let label = if lw > w {
truncate_to_width(label, w)
} else {
label.clone()
};
cur_w += UnicodeWidthStr::width(label.as_str());
cur.push(CellSeg::Link {
label,
url: url.clone(),
});
}
}
}
if !cur.is_empty() {
lines.push(cur);
}
if lines.is_empty() {
lines.push(Vec::new());
}
lines
}
fn render_table(raw: &str, width: u16, icons: bool) -> Vec<Line<'static>> {
let mut rows: Vec<Vec<Vec<CellSeg>>> = Vec::new();
let mut header_rows = 0usize; let mut aligns: Vec<ColAlign> = Vec::new();
for line in raw.lines() {
if is_table_delimiter(line) {
header_rows = rows.len();
aligns = parse_table_aligns(line); continue;
}
rows.push(
parse_table_row(line)
.into_iter()
.map(|c| {
let mut segs = parse_cell_segments(&c);
if icons {
for seg in &mut segs {
if let CellSeg::Link { label, .. } = seg {
*label = format!("{} {label}", crate::ui::icons::link_icon());
}
}
}
segs
})
.collect(),
);
}
let ncol = rows.iter().map(|r| r.len()).max().unwrap_or(0);
if rows.is_empty() || ncol == 0 {
return Vec::new();
}
for r in &mut rows {
r.resize(ncol, Vec::new());
}
let mut col_w = vec![1usize; ncol];
for r in &rows {
for (c, cell) in r.iter().enumerate() {
col_w[c] = col_w[c].max(segs_width(cell));
}
}
let frame = (ncol + 1) + 2 * ncol;
let budget = (width as usize).saturating_sub(frame).max(ncol);
let mut total: usize = col_w.iter().sum();
while total > budget {
let (mi, &mw) = col_w.iter().enumerate().max_by_key(|(_, &w)| w).unwrap();
if mw <= 1 {
break;
}
col_w[mi] -= 1;
total -= 1;
}
let border = Style::new().fg(TABLE_BORDER_FG);
let rule = |left: char, mid: char, right: char| -> Line<'static> {
let mut s = String::new();
s.push(left);
for (c, w) in col_w.iter().enumerate() {
for _ in 0..(w + 2) {
s.push('─');
}
s.push(if c + 1 == ncol { right } else { mid });
}
Line::from(Span::styled(s, border))
};
let mut out = Vec::new();
out.push(rule('┌', '┬', '┐'));
for (ri, r) in rows.iter().enumerate() {
let is_head = ri < header_rows;
let wrapped: Vec<Vec<Vec<CellSeg>>> = r
.iter()
.enumerate()
.map(|(c, cell)| wrap_segments(cell, col_w[c]))
.collect();
let phys = wrapped.iter().map(|w| w.len().max(1)).max().unwrap_or(1);
let cell_style = if is_head {
Style::new().fg(HEAD_FG).add_modifier(Modifier::BOLD)
} else {
Style::new()
};
for p in 0..phys {
let mut spans: Vec<Span<'static>> = vec![Span::styled("│", border)];
for c in 0..ncol {
let segs: &[CellSeg] = wrapped[c].get(p).map(|v| v.as_slice()).unwrap_or(&[]);
let pad = col_w[c].saturating_sub(segs_width(segs));
let (lp, rp) = match aligns.get(c).copied().unwrap_or(ColAlign::Left) {
ColAlign::Left => (0, pad),
ColAlign::Right => (pad, 0),
ColAlign::Center => (pad / 2, pad - pad / 2),
};
spans.push(Span::styled(format!(" {}", " ".repeat(lp)), cell_style));
for seg in segs {
match seg {
CellSeg::Text { text, style } => {
spans.push(Span::styled(text.clone(), cell_style.patch(*style)))
}
CellSeg::Link { label, url } => {
spans.push(Span::styled(label.clone(), link_label_style()));
spans.push(Span::styled(url.clone(), hidden_link_target_style()));
}
}
}
spans.push(Span::styled(format!("{} ", " ".repeat(rp)), cell_style));
spans.push(Span::styled("│", border));
}
out.push(Line::from(spans));
}
if header_rows > 0 && ri + 1 == header_rows {
out.push(rule('├', '┼', '┤'));
}
}
out.push(rule('└', '┴', '┘'));
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::DEFAULT_CODE_BG;
const BG: CodeStyle = CodeStyle {
bg: Some(DEFAULT_CODE_BG),
label_bg: Some(Color::Rgb(70, 78, 99)), label_right: true,
tab_width: 4,
wrap: true,
};
const NO_CODE: CodeStyle = CodeStyle {
bg: None,
label_bg: None,
label_right: true,
tab_width: 4,
wrap: true,
};
fn line_disp_width(l: &Line<'_>) -> usize {
let s: String = l.spans.iter().map(|sp| sp.content.as_ref()).collect();
UnicodeWidthStr::width(s.as_str())
}
#[test]
fn 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_link_rows_align_after_hiding_targets() {
let md = "| name | doc |\n|---|---|\n| konoma | [Docs](./docs/readme.md) |\n| plain | text cell |\n";
let lines = render_markdown(md, 60, BG, "TwoDark", false);
let widths: Vec<usize> = lines
.iter()
.map(|l| {
l.spans
.iter()
.filter(|sp| !is_hidden_link_target(sp))
.map(|sp| UnicodeWidthStr::width(sp.content.as_ref()))
.sum()
})
.collect();
assert!(!widths.is_empty());
assert!(
widths.iter().all(|w| *w == widths[0]),
"行の表示幅が揃わない: {widths:?}"
);
}
#[test]
fn table_link_wraps_atomically_in_narrow_width() {
let md = "| doc |\n|---|\n| intro text [Guide](./guide.md) tail |\n";
let lines = render_markdown(md, 18, BG, "TwoDark", false);
let mut found = false;
for l in &lines {
if let Some(i) = l.spans.iter().position(|sp| sp.content.as_ref() == "Guide") {
assert!(is_hidden_link_target(&l.spans[i + 1]));
found = true;
}
}
assert!(found, "折返し後もリンクラベルが1スパンで残る");
let widths: Vec<usize> = lines
.iter()
.map(|l| {
l.spans
.iter()
.filter(|sp| !is_hidden_link_target(sp))
.map(|sp| UnicodeWidthStr::width(sp.content.as_ref()))
.sum()
})
.collect();
assert!(widths.iter().all(|w| *w == widths[0]), "{widths:?}");
}
#[test]
fn cell_segments_parse_links_and_leave_images_as_text() {
let segs = parse_cell_segments("see [a](b.md) end");
assert_eq!(segs.len(), 3);
assert!(matches!(&segs[1], CellSeg::Link { label, url } if label == "a" && url == "b.md"));
let img = parse_cell_segments("");
assert!(matches!(&img[..], [CellSeg::Text { text, .. }] if text == ""));
let broken = parse_cell_segments("[no url] and [y](");
assert!(broken.iter().all(|s| matches!(s, CellSeg::Text { .. })));
let titled = parse_cell_segments("[t](./g.md \"Title\")");
assert!(
matches!(&titled[..], [CellSeg::Link { url, .. }] if url == "./g.md"),
"title がリンク先に混入しない"
);
let angled = parse_cell_segments("[t](<./with space.md>)");
assert!(
matches!(&angled[..], [CellSeg::Link { url, .. }] if url == "./with space.md"),
"<> 囲みは中身だけ"
);
}
#[test]
fn table_link_icon_matches_paragraph_links_and_keeps_alignment() {
let md = "| a | b |\n|---|---|\n| [Docs](./g.md) | plain |\n";
let lines = render_markdown(md, 60, BG, "TwoDark", true);
let row = lines
.iter()
.find(|l| {
l.spans
.iter()
.any(|sp| sp.content.as_ref().contains("Docs"))
})
.expect("リンク行");
let icon = crate::ui::icons::link_icon();
let label = row
.spans
.iter()
.find(|sp| sp.content.as_ref().contains("Docs"))
.unwrap();
assert!(
label.content.as_ref().starts_with(&format!("{icon} ")),
"アイコンが前置される: {:?}",
label.content
);
let widths: Vec<usize> = lines
.iter()
.map(|l| {
l.spans
.iter()
.filter(|sp| !is_hidden_link_target(sp))
.map(|sp| UnicodeWidthStr::width(sp.content.as_ref()))
.sum()
})
.collect();
assert!(widths.iter().all(|w| *w == widths[0]), "{widths:?}");
}
#[test]
fn table_escaped_pipe_stays_in_one_cell() {
let md = "| a | b |\n|---|---|\n| x \\| y | z |\n";
let lines = render_markdown(md, 60, BG, "TwoDark", false);
let row: String = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|sp| sp.content.as_ref())
.collect::<String>()
})
.find(|t| t.contains("x | y"))
.expect("エスケープパイプがリテラルで残る");
assert_eq!(
row.matches('│').count(),
3,
"2列のまま(幽霊列なし): {row:?}"
);
}
#[test]
fn table_alignment_colons_are_respected() {
let md = "| xxxx | yyyy | zzzz |\n|:-----|:----:|-----:|\n| a | b | c |\n";
let lines = render_markdown(md, 60, BG, "TwoDark", false);
let row: String = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|sp| sp.content.as_ref())
.collect::<String>()
})
.find(|t| t.contains(" a ") && t.contains('│'))
.expect("データ行");
assert_eq!(row, "│ a │ b │ c │", "左/中央/右の整列: {row:?}");
}
#[test]
fn table_cell_inline_styles_render_without_markers() {
let md = "| a |\n|---|\n| **b** and *i* and `c` and ~~s~~ |\n";
let lines = render_markdown(md, 60, BG, "TwoDark", false);
let row = lines
.iter()
.find(|l| l.spans.iter().any(|sp| sp.content.as_ref() == "b"))
.expect("スタイルセル行");
let joined: String = row.spans.iter().map(|sp| sp.content.as_ref()).collect();
assert!(
!joined.contains('*') && !joined.contains('`') && !joined.contains('~'),
"生の記号が残っている: {joined:?}"
);
let has = |txt: &str, m: Modifier| {
row.spans
.iter()
.any(|sp| sp.content.as_ref() == txt && sp.style.add_modifier.contains(m))
};
assert!(has("b", Modifier::BOLD), "bold");
assert!(has("i", Modifier::ITALIC), "italic");
assert!(has("s", Modifier::CROSSED_OUT), "strike");
assert!(
row.spans
.iter()
.any(|sp| sp.content.as_ref() == "c" && sp.style.fg == Some(Color::White)),
"code fg"
);
let plain = parse_cell_segments("2 * 3 * 4");
assert!(matches!(&plain[..], [CellSeg::Text { text, .. }] if text == "2 * 3 * 4"));
}
#[test]
fn details_open_tag_and_split_details() {
assert_eq!(details_open_tag("<details>"), Some(false));
assert_eq!(details_open_tag("<details open>"), Some(true));
assert_eq!(details_open_tag(" <DETAILS OPEN>"), Some(true));
assert_eq!(details_open_tag("<detailsx>"), None);
assert_eq!(details_open_tag("plain"), None);
let md =
"intro\n\n<details open>\n<summary>Sum</summary>\n\nbody line\n\n</details>\n\ntail\n";
let parts = split_details(&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_markdown_tasks_opts(
&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) = render_markdown_with_images(
"text $x^2$ more\n",
40,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_: &str| ImageSlot::Unavailable,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&img_slot,
true,
);
assert_eq!(imgs.len(), 1, "one math placement");
assert!(is_math_url(&imgs[0].url));
let (raw_lines, raw_imgs) = render_markdown_with_images(
"text $x^2$ more\n",
40,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_: &str| ImageSlot::Unavailable,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Raw,
true,
);
assert!(raw_imgs.is_empty());
let joined: String = raw_lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(joined.contains("$x^2$"), "raw LaTeX shown: {joined:?}");
let (_l, off_imgs) = render_markdown_with_images(
"text $x^2$ more\n",
40,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_: &str| ImageSlot::Unavailable,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
assert!(off_imgs.is_empty(), "math off: no placements");
}
#[test]
fn malformed_summary_tag_does_not_panic() {
for md in [
"<details>\n<summary attr\n</summary>\nbody\n</details>\n",
"<details>\n<summary bad</summary>tail\n</details>\n",
"<details>\n<summary>日本語 attr\n</summary>\n本文\n</details>\n",
] {
let _ = render_markdown(md, 40, BG, "TwoDark", false); }
let (sum, body) = extract_summary_body("<summary attr\nbody");
assert_eq!(sum, "");
assert!(body.contains("body"));
}
#[test]
fn nested_details_do_not_drift_later_block_open_state() {
let md = "<details>\n<summary>A</summary>\n<details>\n<summary>Nested</summary>\n</details>\n</details>\n\n<details open>\n<summary>C</summary>\nc body\n</details>\n";
assert_eq!(
collect_details_open(md),
vec![false, true],
"top-level only: outer(closed) + C(open)"
);
set_details_open(collect_details_open(md));
let lines = render_markdown_tasks_opts(
&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_markdown_tasks_opts(
&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_markdown_tasks_opts(
&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_markdown_tasks_opts(
&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_markdown_tasks_opts(
&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_markdown_tasks_opts(
&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_markdown_tasks_opts(
&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();
assert!(
all.iter().any(|t| t.contains("- a")),
"内容は読める形で残る: {all:?}"
);
let markers: Vec<String> = lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|s| is_task_span(s))
.map(|s| s.content.to_string())
.collect();
assert_eq!(markers, vec!["[ ] "], "タスクが装飾のまま: {all:?}");
assert!(
all.iter().any(|t| t.contains("bold") && !t.contains("**")),
"bold が描画される: {all:?}"
);
assert!(
all.iter().all(|t| !t.contains("# title")),
"見出しの # が剥がれる: {all:?}"
);
}
#[test]
fn split_block_for_retry_avoids_fences() {
let src = "para1\n\n```\ncode\n\nmore\n```\n\npara2\n";
let (a, b) = split_block_for_retry(src).expect("分割できる");
assert!(
a.matches("```").count() % 2 == 0,
"前半のフェンスは閉じている: {a:?}"
);
assert!(
b.matches("```").count() % 2 == 0,
"後半のフェンスは閉じている: {b:?}"
);
assert!(split_block_for_retry("only-one-line").is_none());
}
#[test]
fn task_markers_become_dedicated_spans_with_custom_states() {
let md = "- [ ] open\n- [x] done\n- [/] doing\n";
let dflt = render_markdown(md, 40, BG, "TwoDark", false);
let markers = |lines: &[Line<'static>]| -> Vec<String> {
lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|s| is_task_span(s))
.map(|s| s.content.to_string())
.collect()
};
assert_eq!(markers(&dflt), vec!["[ ] ", "[x] "], "既定では / は対象外");
let custom = render_markdown_tasks(md, 40, BG, "TwoDark", false, &[' ', '/', 'x']);
assert_eq!(markers(&custom), vec!["[ ] ", "[x] ", "[/] "]);
let nf_off = format!("{} ", crate::ui::icons::task_icon(false));
let nf_on = format!("{} ", crate::ui::icons::task_icon(true));
let iconed = render_markdown_tasks(md, 40, BG, "TwoDark", true, &[' ', '/', 'x']);
assert_eq!(
markers(&iconed),
vec![nf_off.clone(), nf_on.clone(), "[/] ".into()]
);
assert_eq!(
task_span_state(&nf_off),
Some(' '),
"末尾スペース込みで復元"
);
assert_eq!(task_span_state(&nf_on), Some('x'));
assert_eq!(task_span_state("[ ]"), Some(' '));
assert_eq!(task_span_state("[/]"), Some('/'));
assert_eq!(task_span_state("[ab]"), None, "2文字はマーカーでない");
let mid = render_markdown("text with [ ] brackets\n", 40, BG, "TwoDark", false);
assert!(mid
.iter()
.flat_map(|l| l.spans.iter())
.all(|s| !is_task_span(s)));
}
#[test]
fn task_source_locs_skip_fences_html_and_tables() {
let src = "\
- [ ] first
```
- [x] in fence
```
<details>
- [x] in html block
</details>
| a | b |
|---|---|
| - [ ] cell | x |
- [X] nested
- [/] custom
本文 [ ] は対象外
";
let locs = task_source_locs(src, &[' ', '/', 'x'], &[]);
let got: Vec<(usize, char)> = locs.iter().map(|l| (l.line, l.state)).collect();
assert_eq!(
got,
vec![(0, ' '), (12, 'X'), (13, '/')],
"実タスクのみ: {got:?}"
);
let lines: Vec<&str> = src.lines().collect();
for l in &locs {
assert!(
lines[l.line][l.state_off..].starts_with(l.state),
"offset mismatch at line {}",
l.line
);
}
}
#[test]
fn 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 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| ImageSlot::Inline { cols: 10, rows: 4 };
let (_lines, imgs) = render_markdown_with_images(
src,
40,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
&slot_of,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
assert!(imgs.is_empty(), "fence 内の画像を誤検出: {imgs:?}");
}
#[test]
fn block_image_reserves_rows_and_records_placement() {
let src = "# Title\n\n\n\nbody\n";
let slot_of = |_: &str| ImageSlot::Inline { cols: 20, rows: 5 };
let (lines, imgs) = render_markdown_with_images(
src,
40,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
&slot_of,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
assert_eq!(imgs.len(), 1);
let p = &imgs[0];
assert_eq!((p.cols, p.rows), (20, 5));
assert_eq!(p.url, "hero.png");
assert_eq!(p.alt, "hero");
assert!(p.line < lines.len());
assert!(
lines[p.line].to_string().contains("hero"),
"placeholder label 無し"
);
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(joined.contains("body"), "画像後の本文が消えた");
}
#[test]
fn image_without_backend_degrades_to_text() {
let src = "\n";
let slot_of = |_: &str| ImageSlot::Unavailable; let (lines, imgs) = render_markdown_with_images(
src,
40,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
&slot_of,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
assert!(imgs.is_empty());
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(joined.contains("alt"), "alt テキストが無い: {joined}");
assert!(joined.contains("missing.png"));
}
#[test]
fn remote_image_shows_loading_line() {
let src = "\n";
let slot_of = |_: &str| ImageSlot::Loading;
let (lines, imgs) = render_markdown_with_images(
src,
60,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
&slot_of,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
assert!(imgs.is_empty(), "loading 中は placement を出さない");
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(joined.contains("loading"), "loading 表示が無い: {joined}");
}
#[test]
fn collect_remote_image_urls_finds_http_only() {
let src = "\


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

```
";
let urls = collect_remote_image_urls(src);
assert_eq!(
urls,
vec![
"https://example.com/remote.png".to_string(),
"http://example.com/html.png".to_string(),
],
"remote のみ・fence 内は除外・順序保持: {urls:?}"
);
}
#[test]
fn code_block_cache_hits_are_identical_and_bounded() {
let body: Vec<String> = vec!["fn main() {}".into(), "let x = 1;".into()];
let a = highlight_body(&body, "rust", 60, None, "TwoDark", 4, true);
let b = highlight_body(&body, "rust", 60, None, "TwoDark", 4, true);
assert_eq!(a.len(), b.len());
for (la, lb) in a.iter().zip(&b) {
assert_eq!(la.spans.len(), lb.spans.len());
for (sa, sb) in la.spans.iter().zip(&lb.spans) {
assert_eq!(
(sa.content.as_ref(), sa.style),
(sb.content.as_ref(), sb.style)
);
}
}
for i in 0..(CODE_BLOCK_CACHE_CAP + 20) {
let one = vec![format!("let v{i} = {i};")];
let _ = highlight_body(&one, "rust", 60, None, "TwoDark", 4, true);
}
assert!(code_block_cache_len() <= CODE_BLOCK_CACHE_CAP);
}
#[test]
fn mermaid_to_svg_output_rasterizes_with_konoma_resvg() {
let svg = mermaid_to_svg(
"graph TD\n A[ツリー] -->|Enter| B{種別解決}\n B --> C[プレビュー]",
"dark",
)
.expect("mermaid renders to SVG");
assert!(svg.contains("<svg"), "SVG らしい出力");
let img = crate::preview::svg::rasterize_bytes(
svg.as_bytes(),
std::path::Path::new("m.svg"),
400,
)
.expect("konoma の resvg でラスタライズできる");
assert!(img.width() > 0 && img.height() > 0);
}
#[test]
fn mermaid_dark_theme_uses_modern_font_metrics() {
let svg = mermaid_to_svg("graph LR\nA-->B", "dark").unwrap();
assert!(svg.contains("Inter"), "modern のフォントファミリで計測");
assert!(!svg.contains("trebuchet"), "dark 固有フォントは使わない");
}
#[test]
fn mermaid_to_svg_fails_safely_on_garbage() {
assert!(mermaid_to_svg("definitely not a diagram !!!", "dark").is_none());
}
#[test]
fn catch_silent_returns_none_on_panic_and_some_on_success() {
assert_eq!(catch_silent(|| 42), Some(42));
let paniced: Option<i32> = catch_silent(|| panic!("worker blew up"));
assert_eq!(paniced, None);
PANIC_SILENCED.with(|c| assert!(!c.get(), "panic 経路でも抑制フラグが残らない"));
assert_eq!(catch_silent(|| "ok"), Some("ok"));
}
#[test]
fn 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!(collect_mermaid_fences(unterminated).is_empty());
}
#[test]
fn mermaid_slots_image_loading_text() {
let src = "before\n\n```mermaid\ngraph LR\nA-->B\n```\n\nafter\n";
let slot_img = |_: &str| MermaidSlot::Image { cols: 20, rows: 5 };
let (lines, imgs) = render_markdown_with_images(
src,
60,
CodeStyle::default(),
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_| ImageSlot::Unavailable,
&slot_img,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
assert_eq!(imgs.len(), 1, "フェンスが placement になる");
assert!(is_mermaid_fence_url(&imgs[0].url), "合成キー URL");
let cap = &lines[imgs[0].line - 1];
assert!(
cap.spans.iter().any(is_mermaid_header_span),
"キャプション行に番兵 span"
);
let (lines, imgs) = render_markdown_with_images(
src,
60,
CodeStyle::default(),
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_| ImageSlot::Unavailable,
&|_: &str| MermaidSlot::Loading,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
assert!(imgs.is_empty());
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(joined.contains("loading"), "ローディング行: {joined}");
let (lines, imgs) = render_markdown_with_images(
src,
60,
CodeStyle::default(),
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_| ImageSlot::Unavailable,
&|_: &str| MermaidSlot::Text,
"Enter: full screen",
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
);
assert!(imgs.is_empty());
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(
joined.contains('A') && joined.contains('B'),
"テキスト図として描画: {joined}"
);
}
#[test]
fn fence_caption_is_localizable_but_sentinel_survives() {
let src = "```mermaid\ngraph LR\nA-->B\n```\n";
let slot_img = |_: &str| MermaidSlot::Image { cols: 20, rows: 5 };
let render = |caption: &str| {
render_markdown_with_images(
src,
60,
CodeStyle::default(),
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_| ImageSlot::Unavailable,
&slot_img,
caption,
true,
&|_: &str, _: bool| MathSlot::Raw,
false,
)
};
let (en, ei) = render("Enter: full screen");
let (ja, ji) = render("Enter: 全画面");
let cap_en = en[ei[0].line - 1].to_string();
let cap_ja = ja[ji[0].line - 1].to_string();
assert!(
cap_en.contains("Enter: full screen"),
"en キャプション: {cap_en}"
);
assert!(
cap_ja.contains("Enter: 全画面"),
"ja キャプション: {cap_ja}"
);
assert_ne!(cap_en, cap_ja, "言語でキャプションが変わる");
for (lines, imgs) in [(en, ei), (ja, ji)] {
assert!(
lines[imgs[0].line - 1]
.spans
.iter()
.any(is_mermaid_header_span),
"番兵 span が残る"
);
}
}
#[test]
fn parse_alert_header_recognizes_types_and_aliases() {
assert_eq!(parse_alert_header("> [!NOTE]").unwrap().0, AlertKind::Note);
assert_eq!(
parse_alert_header("> [!warning]").unwrap().0,
AlertKind::Warning
);
let (k, title) = parse_alert_header("> [!danger] Watch out").unwrap();
assert_eq!(k, AlertKind::Caution);
assert_eq!(title, "Watch out");
assert!(parse_alert_header("> just a quote").is_none());
assert!(parse_alert_header("> [!NOPE]").is_none());
assert!(parse_alert_header("[!NOTE]").is_none()); }
#[test]
fn split_alerts_captures_body_and_surrounding_text() {
let md = "intro\n\n> [!TIP]\n> be **bold**\n> line two\n\nafter\n";
let parts = split_alerts(&doc_run(md));
assert_eq!(parts.len(), 3);
assert!(matches!(&parts[0], AlertPart::Text(t) if t.text().contains("intro")));
match &parts[1] {
AlertPart::Alert { kind, body, .. } => {
assert_eq!(*kind, AlertKind::Tip);
assert!(body.contains("be **bold**") && body.contains("line two"));
assert!(!body.contains('>'), "blockquote markers stripped from body");
}
_ => panic!("expected an alert"),
}
assert!(matches!(&parts[2], AlertPart::Text(t) if t.text().contains("after")));
}
#[test]
fn render_alert_makes_a_colored_callout_not_literal_marker() {
let md = "> [!WARNING]\n> careful with [docs](./x.md)\n";
let on = render_markdown_tasks_opts(
&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_markdown_tasks_opts(
&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 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, _) = render_markdown_with_images(
&pre,
100,
NO_CODE,
"TwoDark",
false,
&[' ', 'x'],
&|_: &str| 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) = render_markdown_with_images(
&pre,
70,
NO_CODE,
"TwoDark",
false,
&[' ', 'x'],
&|_: &str| 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:#?}"
);
if !name.starts_with("quote/multi") {
assert!(
!texts.iter().any(|t| t.contains("</a>")),
"{name}: 生の HTML タグが画面に漏れている: {texts:#?}"
);
}
}
}
#[test]
fn banner_quoted_multiline_still_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>")),
"既知の残存(引用内の断片が救済されない)が消えていたらこのテストを畳んでよい: {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 heading_level_hint_infers_levels_from_style() {
let md = "# H1\n\n## H2\n\n### H3\n\n#### H4\n\n##### H5\n";
let lines = render_markdown(md, 40, NO_CODE, "TwoDark", false);
let levels: Vec<u8> = lines
.iter()
.enumerate()
.filter(|(_, l)| heading_text(l).is_some())
.map(|(i, l)| heading_level_hint(l, lines.get(i + 1)))
.collect();
assert_eq!(levels, vec![1, 2, 3, 4, 4]);
}
#[test]
fn heading_inside_alert_strips_bar_for_anchor() {
let md = "> [!NOTE]\n> ## Sub Heading\n> body\n";
let lines = render_markdown_tasks_opts(
&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_markdown_tasks_opts(
&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"),
(
"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",
),
("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",
),
(
"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",
),
(
"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 indented line right after a table block, with no blank line between",
"| a | b |\n|---|---|\n code here\n",
),
(
"an indented line swallowed by an HTML comment block is not code",
"<!-- note -->\n code here\n",
),
(
"an indented line swallowed by an inline-tag block that interrupts a paragraph",
"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",
),
]
}
}
#[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)],
),
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(),
2
);
}
}
#[cfg(test)]
mod task_scan_parity_tests {
use super::*;
#[test]
fn tui_markdown_codeblock_lines_are_styled_white() {
fn check(label: &str, src: &str, expected: &[(&str, bool)]) {
let opts = Options::new(KonomaStyles { code_bg: None });
let text = tui_markdown::from_str_with_options(src, &opts);
let actual: Vec<(String, bool)> = text
.lines
.iter()
.map(|l| (l.to_string(), is_code_block_line(l)))
.collect();
let expected: Vec<(String, bool)> =
expected.iter().map(|(t, w)| (t.to_string(), *w)).collect();
assert_eq!(
actual, expected,
"{label}: tui-markdown の出力の前提が変わった — is_code_block_line が依拠する\
「コードブロックの行は fg(White) で塗られる」という前提が崩れている\
(このテストが落ちたら decorate_code_blocks は静かに壊れる。\
tui-markdown のバージョン/挙動を確認すること)"
);
}
check(
"adjacent blocks",
"```a\nx\n```\n```b\ny\n```\n",
&[
("```a", true),
("x", true),
("```", true),
("", false),
("```b", true),
("y", true),
("```", true),
],
);
check(
"4-backtick fence nesting a 3-backtick lookalike",
"````markdown\n```rust\nlet x = 1;\n```\n````\n\n# Heading After\n\nReal prose here.\n",
&[
("```markdown", true),
("```rust", true),
("let x = 1;", true),
("```", true),
("```", true),
("", false),
("# Heading After", false),
("", false),
("Real prose here.", false),
],
);
}
#[test]
fn decorate_code_blocks_render_non_regressions() {
fn header_line<'a>(lines: &'a [Line<'static>]) -> &'a Line<'static> {
lines
.iter()
.find(|l| l.spans.iter().any(is_code_header_span))
.expect("コードヘッダが描かれていない")
}
let src = "```rust\nfn a(){}\n```\n";
assert_eq!(rendered_code_blocks(src), 1);
let lines = render_markdown(src, 40, CodeStyle::default(), "TwoDark", false);
assert!(
header_line(&lines).to_string().contains("rust"),
"言語ラベルが描かれていない"
);
assert!(
lines.iter().any(|l| l.to_string().contains("fn a(){}")),
"本文が描かれていない"
);
let src = "```\nplain body\n```\n";
assert_eq!(rendered_code_blocks(src), 1);
let lines = render_markdown(src, 40, CodeStyle::default(), "TwoDark", false);
assert!(
header_line(&lines).to_string().contains("code"),
"無ラベルは code と表示されるはず"
);
assert!(lines.iter().any(|l| l.to_string().contains("plain body")));
let src = "```rust\n```\n";
assert_eq!(rendered_code_blocks(src), 1);
let lines = render_markdown(src, 40, CodeStyle::default(), "TwoDark", false);
assert!(
!lines.iter().any(|l| l.to_string().trim() == "```"),
"空フェンスにフェンス記号が生テキストとして残ってはいけない"
);
let src = "para\n\n indented body\n";
assert_eq!(rendered_code_blocks(src), 1);
let lines = render_markdown(src, 40, CodeStyle::default(), "TwoDark", false);
assert!(lines
.iter()
.any(|l| l.to_string().contains("indented body")));
let src = "```a\nfirst\n```\n\n```b\nsecond\n```\n";
assert_eq!(
rendered_code_blocks(src),
2,
"2つの独立したブロックが1つに融合していないか"
);
let lines = render_markdown(src, 40, CodeStyle::default(), "TwoDark", false);
let first = lines
.iter()
.find(|l| l.to_string().contains("first"))
.expect("1つ目の本文が無い");
let second = lines
.iter()
.find(|l| l.to_string().contains("second"))
.expect("2つ目の本文が無い");
assert!(
!first.to_string().contains("second"),
"1つ目の本文に2つ目が混ざっている"
);
assert!(
!second.to_string().contains("first"),
"2つ目の本文に1つ目が混ざっている"
);
let src = "~~~rust\ntilde body\n~~~\n";
assert_eq!(rendered_code_blocks(src), 1);
let lines = render_markdown(src, 40, CodeStyle::default(), "TwoDark", false);
assert!(lines.iter().any(|l| l.to_string().contains("tilde body")));
let src = "`````rust\nfive body\n`````\n";
assert_eq!(rendered_code_blocks(src), 1);
let lines = render_markdown(src, 40, CodeStyle::default(), "TwoDark", false);
assert!(lines.iter().any(|l| l.to_string().contains("five body")));
let src = "> [!NOTE]\n> ```rust\n> alert body\n> ```\n";
assert_eq!(rendered_code_blocks(src), 1);
let lines = render_markdown(src, 40, CodeStyle::default(), "TwoDark", false);
let body = lines
.iter()
.find(|l| l.to_string().contains("alert body"))
.expect("アラート内の本文が描かれていない");
assert!(
!body.to_string().trim_start().starts_with('>'),
"`>` がアラート本文に残ってはいけない"
);
let src =
"<details open>\n<summary>S</summary>\n\n```rust\ndetails body\n```\n\n</details>\n";
assert_eq!(rendered_code_blocks(src), 1);
let lines = render_markdown(src, 40, CodeStyle::default(), "TwoDark", false);
assert!(lines.iter().any(|l| l.to_string().contains("details body")));
}
#[test]
fn decorate_code_blocks_degrades_a_malformed_white_run_unchanged() {
let lonely = vec![Line::styled("stray", Style::new().fg(Color::White))];
let out = decorate_code_blocks(lonely, 40, CodeStyle::default(), "TwoDark");
let texts: Vec<String> = out.iter().map(|l| l.to_string()).collect();
assert_eq!(
texts,
vec!["stray".to_string()],
"長さ1の run はそのまま素通しされるべき(パニックしない)"
);
let blockquote_like = vec![
Line::styled("> ```", Style::new().fg(Color::White)),
Line::styled("> code", Style::new().fg(Color::White)),
Line::styled("> ```", Style::new().fg(Color::White)),
];
let out = decorate_code_blocks(blockquote_like, 40, CodeStyle::default(), "TwoDark");
let texts: Vec<String> = out.iter().map(|l| l.to_string()).collect();
assert_eq!(
texts,
vec![
"> ```".to_string(),
"> code".to_string(),
"> ```".to_string()
],
"先頭が ``` で始まらない run はコードブロックとして解釈されず素通しされるべき"
);
let opts = Options::new(KonomaStyles { code_bg: None });
let text = tui_markdown::from_str_with_options("> para\n>\n> code\n", &opts);
let actual: Vec<(String, bool)> = text
.lines
.iter()
.map(|l| (l.to_string(), is_code_block_line(l)))
.collect();
assert_eq!(
actual,
vec![
("> para".to_string(), false),
("> ".to_string(), false),
("> ```".to_string(), true),
("> code".to_string(), true),
("> ```".to_string(), true),
],
"tui-markdown の前提が変わった(ブロック引用内の字下げコードの形)"
);
assert_eq!(rendered_code_blocks("> para\n>\n> code\n"), 0);
}
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()
}
#[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();
assert_eq!(
drawn, scanned,
"{name}: 画面のチェックボックス数と書き戻しスキャナの数が食い違う\
(この文書ではトグルが全部中止される)\n--- src ---\n{src}"
);
}
}
fn rendered_code_blocks(src: &str) -> usize {
set_details_open(Vec::new());
let (lines, _) = render_markdown_with_images(
src,
100,
CodeStyle::default(),
"TwoDark",
false,
&[' ', 'x'],
&|_: &str| 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 code_block_scanner_matches_renderer_across_indented_code_corpus() {
for (name, src) in code_corpus::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();
assert_eq!(
drawn, scanned,
"{name}: 画面のチェックボックス数と書き戻しスキャナの数が食い違う\
(この文書ではトグルが全部中止される)\n--- src ---\n{src}"
);
}
}
fn rendered_texts(src: &str) -> Vec<String> {
set_details_open(Vec::new());
let (lines, _) = render_markdown_with_images(
src,
100,
CodeStyle::default(),
"TwoDark",
false,
&[' ', 'x'],
&|_: &str| 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| 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 し直す必要がある)"
);
}
#[test]
fn tui_markdown_runs_an_indented_blocks_adjacent_lines_together() {
fn upstream(src: &str) -> Vec<(String, bool)> {
let opts = Options::new(KonomaStyles { code_bg: None });
tui_markdown::from_str_with_options(src, &opts)
.lines
.iter()
.map(|l| (l.to_string(), is_code_block_line(l)))
.collect()
}
assert_eq!(
upstream("para\n\n one\n two\n"),
vec![
("para".to_string(), false),
(String::new(), false),
("```".to_string(), true),
("onetwo".to_string(), true),
("```".to_string(), true),
],
"字下げコードブロックの連続行は tui-markdown 側で1行に連結される\
(konoma の処理ではない)"
);
assert_eq!(
upstream("para\n\n```\none\ntwo\n```\n"),
vec![
("para".to_string(), false),
(String::new(), false),
("```".to_string(), true),
("one".to_string(), true),
("two".to_string(), true),
("```".to_string(), true),
],
"フェンスは1ソース行=1行のまま(連結は字下げブロック固有)"
);
assert_eq!(
upstream("para\n\n one\n\n two\n"),
vec![
("para".to_string(), false),
(String::new(), false),
("```".to_string(), true),
("one".to_string(), true),
("two".to_string(), true),
("```".to_string(), true),
],
"空行で区切られた2チャンクは(1ブロックだが)連結されない=連結は隣接行の話"
);
assert_eq!(
code_block_source_locs("para\n\n one\n two\n", &[]),
vec!["one\ntwo".to_string()],
"コピー対象はファイルの行のまま(画面の連結に引きずられない)"
);
}
#[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 split_block_for_retry_never_splits_inside_a_nested_shorter_fence() {
let src = "````md\n```mermaid\n\ninner blank\n\n```\n````\n";
assert_eq!(split_block_for_retry(src), None);
}
#[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),
0,
"素の(アラートでない)引用内のフェンス/字下げはレンダラ側も未対応(既存の制約)"
);
assert!(
code_block_source_locs(quoted, &[]).is_empty(),
"スキャナも同様に検出しない(食い違いなし・新規リグレッションではなく既知の制約の温存)"
);
}
#[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_markdown_tasks_opts(
&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 dropped", "```mermaid\nno 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) = render_markdown_with_images(
md,
60,
CodeStyle::default(),
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_: &str| 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) = render_markdown_with_images(
md,
60,
CodeStyle::default(),
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_: &str| 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) = render_markdown_with_images(
md,
60,
CodeStyle::default(),
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_: &str| 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) = render_markdown_with_images(
md,
60,
CodeStyle::default(),
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_: &str| 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) = render_markdown_with_images(
md,
60,
CodeStyle::default(),
"TwoDark",
false,
DEFAULT_TASK_STATES,
&|_: &str| 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)]
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_code(src: &str) -> usize {
set_details_open(Vec::new());
let (lines, _) = render_markdown_with_images(
src,
100,
CodeStyle::default(),
"TwoDark",
false,
&[' ', 'x'],
&|_: &str| 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()
}
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
}
#[test]
fn code_scanner_matches_the_render_through_the_app_pipeline() {
for (name, raw) in all_cases() {
let (pre, _) = app_pre(raw);
let drawn = drawn_code(&pre);
let scanned = code_block_source_locs(&pre, &[]).len();
assert_eq!(
drawn, scanned,
"{name}: アプリ経路で画面のコードブロック数とコピー用スキャナの数が食い違う\
(この文書では `y c` が全部拒否される)\n--- raw ---\n{raw}\n--- preprocessed ---\n{pre}"
);
}
}
#[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();
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}"
);
}
}
}
}