use std::collections::HashMap;
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use super::document::{BlockMeta, Document};
use super::math_text::{math_inline, math_source};
use super::node::{Block, CalloutKind, Fold, Inline};
use super::shortcode_extract::{extract_shortcodes_with_config, parse_placeholder, ExtractedShortcode};
use super::url::Url;
use crate::heading::anchor::obsidian_heading_anchor;
#[derive(Debug, Clone, Copy)]
pub struct ParseConfig {
pub emit_source_lines: bool,
pub implicit_figure: bool,
pub source_line_offset: usize,
pub math: bool,
}
impl Default for ParseConfig {
fn default() -> Self {
Self {
emit_source_lines: false,
implicit_figure: true,
source_line_offset: 0,
math: false,
}
}
}
pub fn parser_options(math: bool) -> Options {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_WIKILINKS);
if math {
options.insert(Options::ENABLE_MATH);
}
options
}
pub fn parse(markdown: &str) -> Document {
parse_with_config(markdown, &ParseConfig::default())
}
pub fn parse_with_config(markdown: &str, config: &ParseConfig) -> Document {
let extraction = extract_shortcodes_with_config(markdown, config);
let options = parser_options(config.math);
let (events, offsets): (Vec<Event<'_>>, Vec<Option<std::ops::Range<usize>>>) =
if config.emit_source_lines {
let mut evs = Vec::new();
let mut offs = Vec::new();
for (event, range) in
Parser::new_ext(&extraction.markdown_with_placeholders, options).into_offset_iter()
{
evs.push(event);
offs.push(Some(range));
}
(evs, offs)
} else {
let evs: Vec<Event<'_>> =
Parser::new_ext(&extraction.markdown_with_placeholders, options).collect();
let len = evs.len();
(evs, vec![None; len])
};
let line_lookup = if config.emit_source_lines {
Some(LineLookup::build(
&extraction.markdown_with_placeholders,
config.source_line_offset,
))
} else {
None
};
let line_ctx: Option<LineCtx<'_>> = line_lookup.as_ref().map(|lookup| LineCtx {
lookup,
offsets: &offsets,
});
let mut blocks = Vec::new();
let mut block_meta: Vec<BlockMeta> = Vec::new();
let mut i = 0;
while i < events.len() {
let event_start_idx = i;
let (block, advance) = parse_block(&events, i, line_ctx.as_ref());
if let Some(b) = block {
let source_line = match (line_lookup.as_ref(), offsets.get(event_start_idx)) {
(Some(lookup), Some(Some(range))) => Some(lookup.line_at(range.start)),
_ => None,
};
blocks.push(b);
block_meta.push(BlockMeta { source_line });
}
i += advance.max(1);
}
substitute_shortcode_placeholders(&mut blocks, &extraction.nonce, &extraction.extracted);
if !config.implicit_figure {
for block in blocks.iter_mut() {
unwrap_implicit_figure(block);
}
}
assign_heading_id_suffixes(&mut blocks);
Document::from_blocks_with_meta(blocks, block_meta)
}
fn unwrap_implicit_figure(block: &mut Block) {
if let Block::Figure { image, .. } = block {
let img = std::mem::replace(
image,
Inline::Text(String::new()), );
*block = Block::Paragraph(vec![img]);
return;
}
match block {
Block::BlockQuote(children) => {
for child in children.iter_mut() {
unwrap_implicit_figure(child);
}
}
Block::Callout { children, .. } => {
for child in children.iter_mut() {
unwrap_implicit_figure(child);
}
}
Block::List { items, .. } => {
for item in items.iter_mut() {
for child in item.iter_mut() {
unwrap_implicit_figure(child);
}
}
}
Block::LinkCard { children, .. } => {
for child in children.iter_mut() {
unwrap_implicit_figure(child);
}
}
_ => {}
}
}
struct LineCtx<'a> {
lookup: &'a LineLookup,
offsets: &'a [Option<std::ops::Range<usize>>],
}
impl<'a> LineCtx<'a> {
fn line_at_event(&self, event_index: usize) -> Option<usize> {
match self.offsets.get(event_index) {
Some(Some(range)) => Some(self.lookup.line_at(range.start)),
_ => None,
}
}
}
struct LineLookup {
newline_offsets: Vec<usize>,
line_offset: usize,
}
impl LineLookup {
fn build(source: &str, line_offset: usize) -> Self {
let mut newline_offsets = Vec::new();
for (i, b) in source.bytes().enumerate() {
if b == b'\n' {
newline_offsets.push(i);
}
}
Self {
newline_offsets,
line_offset,
}
}
fn line_at(&self, byte_offset: usize) -> usize {
let body_line = match self.newline_offsets.binary_search(&byte_offset) {
Ok(idx) => idx + 1,
Err(idx) => idx + 1,
};
body_line + self.line_offset
}
}
fn substitute_shortcode_placeholders(
blocks: &mut Vec<Block>,
nonce: &str,
extracted: &[ExtractedShortcode],
) {
for block in blocks.iter_mut() {
if let Block::Other(html) = block {
if let Some(index) = parse_placeholder(nonce, html) {
if let Some(entry) = extracted.iter().find(|e| e.index == index) {
*block = Block::Shortcode(entry.shortcode.clone());
}
}
}
}
}
fn parse_block(
events: &[Event<'_>],
start: usize,
line_ctx: Option<&LineCtx<'_>>,
) -> (Option<Block>, usize) {
match &events[start] {
Event::Start(tag) => parse_block_with_tag(events, start, tag, line_ctx),
Event::Text(_) | Event::Code(_) | Event::Html(_) | Event::SoftBreak | Event::HardBreak => {
(None, 1)
}
Event::End(_) => (None, 1),
Event::Rule => (Some(Block::ThematicBreak), 1),
_ => (None, 1),
}
}
fn parse_block_with_tag(
events: &[Event<'_>],
start: usize,
tag: &Tag<'_>,
line_ctx: Option<&LineCtx<'_>>,
) -> (Option<Block>, usize) {
match tag {
Tag::Heading { level, .. } => {
let (children, end) = collect_inlines_until(events, start + 1, |e| {
matches!(e, Event::End(TagEnd::Heading(_)))
});
let level_num = match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
};
let heading_text = crate::heading::text::events_to_text(events, start + 1, end);
let base_slug = obsidian_heading_anchor(&heading_text);
(
Some(Block::Heading {
level: level_num,
children,
id: Some(base_slug),
}),
end - start + 1,
)
}
Tag::Paragraph => {
let (children, end) = collect_inlines_until(events, start + 1, |e| {
matches!(e, Event::End(TagEnd::Paragraph))
});
let block = match try_promote_to_figure(children, events, start) {
Ok(figure) => figure,
Err(original_inlines) => Block::Paragraph(original_inlines),
};
(Some(block), end - start + 1)
}
Tag::CodeBlock(kind) => {
let lang = match kind {
pulldown_cmark::CodeBlockKind::Fenced(s) if !s.is_empty() => Some(s.to_string()),
_ => None,
};
let mut value = String::new();
let mut i = start + 1;
while i < events.len() {
match &events[i] {
Event::End(TagEnd::CodeBlock) => break,
Event::Text(t) => value.push_str(t),
_ => {}
}
i += 1;
}
(Some(Block::CodeBlock { lang, value }), i - start + 1)
}
Tag::BlockQuote(_) => {
match detect_and_assemble_callout(events, start + 1, line_ctx) {
Some((block, body_end)) => (Some(block), body_end - start + 1),
None => {
let (children, end) = collect_blocks_until(events, start + 1, line_ctx, |e| {
matches!(e, Event::End(TagEnd::BlockQuote(_)))
});
(Some(Block::BlockQuote(children)), end - start + 1)
}
}
}
Tag::List(start_num) => {
let ordered = start_num.is_some();
let list_start = match start_num {
Some(n) if *n != 1 => Some(*n),
_ => None,
};
let mut items: Vec<Vec<Block>> = Vec::new();
let mut item_source_lines: Vec<Option<usize>> = Vec::new();
let track_lines = line_ctx.is_some();
let mut i = start + 1;
while i < events.len() {
match &events[i] {
Event::End(TagEnd::List(_)) => break,
Event::Start(Tag::Item) => {
if track_lines {
item_source_lines.push(line_ctx.and_then(|ctx| ctx.line_at_event(i)));
}
let (item_blocks, end) = collect_item_blocks(events, i + 1, line_ctx);
items.push(item_blocks);
i = end + 1;
}
_ => i += 1,
}
}
(
Some(Block::List {
ordered,
start: list_start,
items,
item_source_lines,
}),
i - start + 1,
)
}
Tag::Table(_) => {
let mut header: Vec<Vec<Inline>> = Vec::new();
let mut rows: Vec<Vec<Vec<Inline>>> = Vec::new();
let mut header_source_line: Option<usize> = None;
let mut row_source_lines: Vec<Option<usize>> = Vec::new();
let track_lines = line_ctx.is_some();
let mut current_row: Vec<Vec<Inline>> = Vec::new();
let mut in_head = false;
let mut in_body_row = false;
let mut i = start + 1;
while i < events.len() {
match &events[i] {
Event::End(TagEnd::Table) => break,
Event::Start(Tag::TableHead) => {
in_head = true;
if track_lines {
header_source_line = line_ctx.and_then(|ctx| ctx.line_at_event(i));
}
i += 1;
}
Event::End(TagEnd::TableHead) => {
in_head = false;
i += 1;
}
Event::Start(Tag::TableRow) => {
in_body_row = true;
current_row = Vec::new();
if track_lines {
row_source_lines.push(line_ctx.and_then(|ctx| ctx.line_at_event(i)));
}
i += 1;
}
Event::End(TagEnd::TableRow) => {
if in_body_row {
rows.push(std::mem::take(&mut current_row));
in_body_row = false;
}
i += 1;
}
Event::Start(Tag::TableCell) => {
let (cell_inlines, end) = collect_inlines_until(events, i + 1, |e| {
matches!(e, Event::End(TagEnd::TableCell))
});
if in_head {
header.push(cell_inlines);
} else {
current_row.push(cell_inlines);
}
i = end + 1;
}
_ => i += 1,
}
}
(
Some(Block::Table {
header,
rows,
header_source_line,
row_source_lines,
}),
i - start + 1,
)
}
Tag::HtmlBlock => {
let mut html = String::new();
let mut i = start + 1;
while i < events.len() {
match &events[i] {
Event::End(TagEnd::HtmlBlock) => break,
Event::Html(s) | Event::Text(s) => html.push_str(s),
_ => {}
}
i += 1;
}
(Some(Block::Other(html)), i - start + 1)
}
_ => (None, 1),
}
}
fn try_promote_to_figure(
inlines: Vec<Inline>,
events: &[Event<'_>],
para_start: usize,
) -> Result<Block, Vec<Inline>> {
let mut image_count = 0;
for inline in &inlines {
match inline {
Inline::Image { .. } => image_count += 1,
Inline::Text(s) if s.trim().is_empty() => {} Inline::LineBreak => {} _ => return Err(inlines),
}
}
if image_count != 1 {
return Err(inlines);
}
if let Some(Inline::Image {
src,
is_wikilink: true,
..
}) = inlines.iter().find(|i| matches!(i, Inline::Image { .. }))
{
let dest = match src {
Url::Unresolved(s) => s.as_str(),
Url::Resolved(r) => r.href.as_str(),
};
let ext = crate::path_ext::path_extension_lower(dest);
if !matches!(
crate::resolve::ext_kind::reference_kind_for_ext(&ext),
crate::resolve::ext_kind::ExtKind::Image
) {
return Err(inlines);
}
}
let mut figure_width: Option<String> = None;
let mut rewritten_alt: Option<String> = None;
match inlines.iter().find(|i| matches!(i, Inline::Image { .. })) {
Some(Inline::Image {
alt,
is_wikilink: false,
..
}) => {
let (rest_alt, w) = crate::media::split_alt_width(alt);
if w.is_some() {
figure_width = w;
rewritten_alt = Some(rest_alt);
}
}
Some(Inline::Image {
is_wikilink: true,
wikilink_pothole,
..
}) => {
if let Some(pothole) = wikilink_pothole {
let (remaining, w) = crate::media::split_alt_width(pothole);
if w.is_some() {
figure_width = w;
rewritten_alt = Some(remaining);
}
}
}
_ => {}
}
let raw_alt = inlines.iter().find_map(|i| match i {
Inline::Image { alt, .. } => Some(alt.as_str()),
_ => None,
});
let alt_text = rewritten_alt
.as_deref()
.or(raw_alt)
.map(|s| s.trim().to_string())
.unwrap_or_default();
if alt_text.is_empty() && figure_width.is_none() {
return Err(inlines);
}
let mut image_owned: Option<Inline> = None;
for inline in inlines.into_iter() {
if matches!(inline, Inline::Image { .. }) {
image_owned = Some(inline);
break;
}
}
let mut image =
image_owned.expect("invariant: image_count == 1 implies one Image present");
if let (Some(new_alt), Inline::Image { alt, .. }) = (rewritten_alt, &mut image) {
*alt = new_alt;
}
let caption = if alt_text.is_empty() {
None
} else {
Some(build_caption_inlines(
&image,
events,
para_start,
alt_text,
figure_width.is_some(),
))
};
Ok(Block::Figure {
image,
caption,
width: figure_width,
align: None,
class_names: Vec::new(),
img_style: None,
})
}
fn build_caption_inlines(
image: &Inline,
events: &[Event<'_>],
para_start: usize,
alt_text: String,
has_width: bool,
) -> Vec<Inline> {
let is_wikilink = matches!(
image,
Inline::Image {
is_wikilink: true,
..
}
);
if is_wikilink || has_width {
return vec![Inline::Text(alt_text)];
}
let mut img_children_start: Option<usize> = None;
let mut i = para_start + 1;
while i < events.len() {
match &events[i] {
Event::Start(Tag::Image { .. }) => {
img_children_start = Some(i + 1);
break;
}
Event::End(TagEnd::Paragraph) => break,
_ => {}
}
i += 1;
}
let Some(children_start) = img_children_start else {
return vec![Inline::Text(alt_text)];
};
let (mut caption, _end) = collect_inlines_until(events, children_start, |e| {
matches!(e, Event::End(TagEnd::Image))
});
if caption.iter().all(|c| matches!(c, Inline::Text(_))) {
return vec![Inline::Text(alt_text)];
}
if let Some(Inline::Text(first)) = caption.first_mut() {
*first = first.trim_start().to_string();
if first.is_empty() {
caption.remove(0);
}
}
if let Some(Inline::Text(last)) = caption.last_mut() {
*last = last.trim_end().to_string();
if last.is_empty() {
caption.pop();
}
}
if caption.is_empty() {
return vec![Inline::Text(alt_text)];
}
caption
}
fn collect_inlines_until<F>(events: &[Event<'_>], start: usize, is_end: F) -> (Vec<Inline>, usize)
where
F: Fn(&Event<'_>) -> bool,
{
let mut out: Vec<Inline> = Vec::new();
let mut i = start;
while i < events.len() {
if is_end(&events[i]) {
return (out, i);
}
let (inline, advance) = parse_inline(events, i);
if let Some(node) = inline {
out.push(node);
}
i += advance.max(1);
}
(out, i)
}
fn parse_inline(events: &[Event<'_>], start: usize) -> (Option<Inline>, usize) {
match &events[start] {
Event::Text(t) => (Some(Inline::Text(t.to_string())), 1),
Event::Code(c) => (Some(Inline::Code(c.to_string())), 1),
Event::SoftBreak => (Some(Inline::Text("\n".to_string())), 1),
Event::HardBreak => (Some(Inline::LineBreak), 1),
Event::Html(s) | Event::InlineHtml(s) => (Some(Inline::Other(s.to_string())), 1),
Event::InlineMath(tex) => (Some(math_inline(tex, false)), 1),
Event::DisplayMath(tex) => (Some(math_inline(tex, true)), 1),
Event::Start(tag) => match tag {
Tag::Emphasis => {
let (children, end) = collect_inlines_until(events, start + 1, |e| {
matches!(e, Event::End(TagEnd::Emphasis))
});
(Some(Inline::Emphasis(children)), end - start + 1)
}
Tag::Strong => {
let (children, end) = collect_inlines_until(events, start + 1, |e| {
matches!(e, Event::End(TagEnd::Strong))
});
(Some(Inline::Strong(children)), end - start + 1)
}
Tag::Link {
link_type,
dest_url,
title,
..
} => {
let (children, end) = collect_inlines_until(events, start + 1, |e| {
matches!(e, Event::End(TagEnd::Link))
});
let title_opt = if title.is_empty() {
None
} else {
Some(title.to_string())
};
let is_wikilink = matches!(*link_type, pulldown_cmark::LinkType::WikiLink { .. });
(
Some(Inline::Link {
url: Url::unresolved(dest_url.to_string()),
title: title_opt,
children,
is_wikilink,
}),
end - start + 1,
)
}
Tag::Image {
link_type,
dest_url,
title,
..
} => {
let mut alt = String::new();
let mut i = start + 1;
while i < events.len() {
match &events[i] {
Event::End(TagEnd::Image) => break,
Event::Text(t) => alt.push_str(t),
Event::Code(c) => alt.push_str(c),
Event::InlineMath(t) => alt.push_str(&math_source(t, false)),
Event::DisplayMath(t) => alt.push_str(&math_source(t, true)),
_ => {}
}
i += 1;
}
let is_wikilink_image =
matches!(link_type, pulldown_cmark::LinkType::WikiLink { .. });
let wikilink_pothole: Option<String> = if is_wikilink_image {
let dest_str: &str = dest_url;
let trimmed = alt.trim();
if trimmed.is_empty() || trimmed == dest_str {
None
} else {
Some(trimmed.to_string())
}
} else {
None
};
if is_wikilink_image {
let dest_str: &str = dest_url;
let trimmed = alt.trim().to_string();
if trimmed.is_empty() || trimmed == dest_str {
alt.clear();
} else if crate::media::is_all_display_keywords(&trimmed) {
alt.clear();
} else {
use crate::resolve::wikilink_dispatch::{
parse_pothole_params, PotholeContent,
};
match parse_pothole_params(&trimmed) {
PotholeContent::Empty | PotholeContent::Params(_) => {
alt.clear();
}
PotholeContent::WidthToken { rest_alias, .. } => {
alt = rest_alias;
}
PotholeContent::Alias(text) => {
let (remaining, _w) = crate::media::split_alt_width(&text);
alt = remaining;
}
}
}
}
let title_opt = if title.is_empty() {
None
} else {
Some(title.to_string())
};
(
Some(Inline::Image {
src: Url::unresolved(dest_url.to_string()),
alt,
title: title_opt,
is_wikilink: is_wikilink_image,
wikilink_pothole,
}),
i - start + 1,
)
}
_ => (None, 1),
},
_ => (None, 1),
}
}
fn collect_blocks_until<F>(
events: &[Event<'_>],
start: usize,
line_ctx: Option<&LineCtx<'_>>,
is_end: F,
) -> (Vec<Block>, usize)
where
F: Fn(&Event<'_>) -> bool,
{
let mut out: Vec<Block> = Vec::new();
let mut i = start;
while i < events.len() {
if is_end(&events[i]) {
return (out, i);
}
let (block, advance) = parse_block(events, i, line_ctx);
if let Some(b) = block {
out.push(b);
}
i += advance.max(1);
}
(out, i)
}
fn collect_item_blocks(
events: &[Event<'_>],
start: usize,
line_ctx: Option<&LineCtx<'_>>,
) -> (Vec<Block>, usize) {
let mut out: Vec<Block> = Vec::new();
let mut pending_inlines: Vec<Inline> = Vec::new();
let mut i = start;
while i < events.len() {
if matches!(&events[i], Event::End(TagEnd::Item)) {
flush_pending_paragraph(&mut out, &mut pending_inlines);
return (out, i);
}
if let Some((inline, advance)) = parse_inline_event(events, i) {
if let Some(node) = inline {
pending_inlines.push(node);
}
i += advance.max(1);
continue;
}
flush_pending_paragraph(&mut out, &mut pending_inlines);
let (block, advance) = parse_block(events, i, line_ctx);
if let Some(b) = block {
out.push(b);
}
i += advance.max(1);
}
flush_pending_paragraph(&mut out, &mut pending_inlines);
(out, i)
}
fn detect_and_assemble_callout(
events: &[Event<'_>],
start: usize,
line_ctx: Option<&LineCtx<'_>>,
) -> Option<(Block, usize)> {
if !matches!(events.get(start), Some(Event::Start(Tag::Paragraph))) {
return None;
}
let mut leading = String::new();
let mut i = start + 1;
while let Some(event) = events.get(i) {
match event {
Event::Text(t) => {
leading.push_str(t);
i += 1;
}
Event::InlineMath(t) => {
leading.push_str(&math_source(t, false));
i += 1;
}
Event::DisplayMath(t) => {
leading.push_str(&math_source(t, true));
i += 1;
}
_ => break,
}
}
if leading.is_empty() {
return None;
}
let (raw_kind, fold, title, _marker_byte_len) = parse_callout_marker(&leading)?;
let kind = CalloutKind::from_raw(raw_kind).unwrap_or(CalloutKind::Note);
let title: Option<String> = title.map(|s| s.to_string()).filter(|s| !s.is_empty());
let mut body_blocks: Vec<Block> = Vec::new();
let body_paragraph_start: Option<usize> = match events.get(i) {
Some(Event::SoftBreak) | Some(Event::HardBreak) => {
Some(i + 1)
}
Some(Event::End(TagEnd::Paragraph)) => {
i += 1;
None
}
_ => {
Some(i)
}
};
if let Some(body_start) = body_paragraph_start {
let (body_inlines, after_para) = collect_inlines_until(events, body_start, |e| {
matches!(e, Event::End(TagEnd::Paragraph))
});
i = after_para + 1;
let trimmed_empty = body_inlines.iter().all(|x| match x {
Inline::Text(t) => t.trim().is_empty(),
_ => false,
});
if !trimmed_empty {
body_blocks.push(Block::Paragraph(body_inlines));
}
}
while let Some(event) = events.get(i) {
if matches!(event, Event::End(TagEnd::BlockQuote(_))) {
break;
}
let (block, advance) = parse_block(events, i, line_ctx);
if let Some(b) = block {
body_blocks.push(b);
}
i += advance.max(1);
}
let block = Block::Callout {
kind,
fold,
title,
children: body_blocks,
};
Some((block, i))
}
fn parse_callout_marker(text: &str) -> Option<(&str, Option<Fold>, Option<&str>, usize)> {
let after_open = text.strip_prefix("[!")?;
let close_offset = after_open.find(']')?;
let raw_kind = &after_open[..close_offset];
if raw_kind.is_empty() || raw_kind.chars().any(|c| c.is_whitespace()) {
return None;
}
let after_bracket_offset = 2 + close_offset + 1;
let rest = &text[after_bracket_offset..];
let (fold, after_fold_offset) = match rest.chars().next() {
Some('+') => (Some(Fold::Open), after_bracket_offset + 1),
Some('-') => (Some(Fold::Closed), after_bracket_offset + 1),
_ => (None, after_bracket_offset),
};
let rest_after_fold = &text[after_fold_offset..];
let (title, marker_byte_len) = if rest_after_fold.is_empty() {
(None, after_fold_offset)
} else if let Some(remainder) = rest_after_fold.strip_prefix(' ') {
let title_str = remainder;
let consumed = after_fold_offset + 1 + remainder.len();
(Some(title_str), consumed)
} else {
(None, after_fold_offset)
};
Some((raw_kind, fold, title, marker_byte_len))
}
fn parse_inline_event(events: &[Event<'_>], i: usize) -> Option<(Option<Inline>, usize)> {
match &events[i] {
Event::Text(_)
| Event::Code(_)
| Event::Html(_)
| Event::InlineHtml(_)
| Event::SoftBreak
| Event::HardBreak
| Event::InlineMath(_)
| Event::DisplayMath(_) => Some(parse_inline(events, i)),
Event::Start(tag) => match tag {
Tag::Emphasis | Tag::Strong | Tag::Link { .. } | Tag::Image { .. } => {
Some(parse_inline(events, i))
}
_ => None,
},
_ => None,
}
}
fn flush_pending_paragraph(out: &mut Vec<Block>, pending_inlines: &mut Vec<Inline>) {
if !pending_inlines.is_empty() {
out.push(Block::Paragraph(std::mem::take(pending_inlines)));
}
}
fn assign_heading_id_suffixes(blocks: &mut [Block]) {
let mut id_counts: HashMap<String, usize> = HashMap::new();
assign_heading_id_suffixes_walk(blocks, &mut id_counts);
}
fn assign_heading_id_suffixes_walk(blocks: &mut [Block], id_counts: &mut HashMap<String, usize>) {
for block in blocks.iter_mut() {
match block {
Block::Heading { id, .. } => {
if let Some(slug) = id {
let count_entry = id_counts.entry(slug.clone()).or_insert(0);
let count = *count_entry;
if count > 0 {
*id = Some(format!("{}-{}", slug, count));
}
*count_entry = count + 1;
}
}
Block::BlockQuote(children) | Block::Callout { children, .. } => {
assign_heading_id_suffixes_walk(children, id_counts);
}
Block::List { items, .. } => {
for item in items.iter_mut() {
assign_heading_id_suffixes_walk(item, id_counts);
}
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::super::node::{CalloutKind, Fold, Inline};
use super::*;
fn first_block(md: &str) -> Block {
parse(md)
.blocks
.into_iter()
.next()
.expect("at least one block")
}
#[test]
fn parses_basic_callout_with_inline_title() {
match first_block("> [!note] Heads up\n> Body line 1.\n") {
Block::Callout {
kind,
fold,
title,
children,
} => {
assert_eq!(kind, CalloutKind::Note);
assert!(fold.is_none(), "non-foldable callout");
assert_eq!(title.as_deref(), Some("Heads up"));
assert!(!children.is_empty(), "body should remain");
}
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn parses_titleless_callout() {
match first_block("> [!warning]\n> Watch out.\n") {
Block::Callout {
kind,
fold,
title,
children,
} => {
assert_eq!(kind, CalloutKind::Warning);
assert!(fold.is_none());
assert!(title.is_none(), "no inline title");
assert!(!children.is_empty());
}
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_alias_tldr_canonicalizes_to_abstract() {
match first_block("> [!tldr] Short summary\n> body\n") {
Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Abstract),
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_alias_hint_canonicalizes_to_tip() {
match first_block("> [!hint] Pro tip\n> body\n") {
Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Tip),
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_alias_important_canonicalizes_to_tip() {
match first_block("> [!important] Read this\n> body\n") {
Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Tip),
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_alias_check_done_canonicalizes_to_success() {
for alias in &["check", "done"] {
let md = format!("> [!{alias}] Yes\n> body\n");
match first_block(&md) {
Block::Callout { kind, .. } => assert_eq!(
kind,
CalloutKind::Success,
"alias `{alias}` should canonicalize to Success"
),
other => panic!("alias `{alias}` — expected Callout, got {other:?}"),
}
}
}
#[test]
fn callout_alias_help_faq_canonicalizes_to_question() {
for alias in &["help", "faq"] {
let md = format!("> [!{alias}] question\n> body\n");
match first_block(&md) {
Block::Callout { kind, .. } => assert_eq!(
kind,
CalloutKind::Question,
"alias `{alias}` should canonicalize to Question"
),
other => panic!("alias `{alias}` — expected Callout, got {other:?}"),
}
}
}
#[test]
fn callout_alias_caution_attention_canonicalizes_to_warning() {
for alias in &["caution", "attention"] {
let md = format!("> [!{alias}] careful\n> body\n");
match first_block(&md) {
Block::Callout { kind, .. } => assert_eq!(
kind,
CalloutKind::Warning,
"alias `{alias}` should canonicalize to Warning"
),
other => panic!("alias `{alias}` — expected Callout, got {other:?}"),
}
}
}
#[test]
fn callout_alias_fail_missing_canonicalizes_to_failure() {
for alias in &["fail", "missing"] {
let md = format!("> [!{alias}] oops\n> body\n");
match first_block(&md) {
Block::Callout { kind, .. } => assert_eq!(
kind,
CalloutKind::Failure,
"alias `{alias}` should canonicalize to Failure"
),
other => panic!("alias `{alias}` — expected Callout, got {other:?}"),
}
}
}
#[test]
fn callout_alias_error_canonicalizes_to_danger() {
match first_block("> [!error] bad\n> body\n") {
Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Danger),
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_alias_cite_canonicalizes_to_quote() {
match first_block("> [!cite] source\n> body\n") {
Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Quote),
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_foldable_open_suffix() {
match first_block("> [!note]+ Open by default\n> body\n") {
Block::Callout {
kind, fold, title, ..
} => {
assert_eq!(kind, CalloutKind::Note);
assert_eq!(fold, Some(Fold::Open));
assert_eq!(title.as_deref(), Some("Open by default"));
}
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_foldable_closed_suffix() {
match first_block("> [!note]- Closed by default\n> body\n") {
Block::Callout {
kind, fold, title, ..
} => {
assert_eq!(kind, CalloutKind::Note);
assert_eq!(fold, Some(Fold::Closed));
assert_eq!(title.as_deref(), Some("Closed by default"));
}
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_foldable_without_title() {
match first_block("> [!tip]+\n> body\n") {
Block::Callout {
kind, fold, title, ..
} => {
assert_eq!(kind, CalloutKind::Tip);
assert_eq!(fold, Some(Fold::Open));
assert!(title.is_none());
}
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_unknown_kind_falls_back_to_note() {
match first_block("> [!unknownkind] body\n") {
Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Note),
other => panic!("expected Callout (fallback to Note), got {other:?}"),
}
}
#[test]
fn callout_multi_paragraph_body_preserves_blocks() {
let md = "> [!info] Multi\n> First paragraph.\n>\n> Second paragraph.\n";
match first_block(md) {
Block::Callout {
kind,
title,
children,
..
} => {
assert_eq!(kind, CalloutKind::Info);
assert_eq!(title.as_deref(), Some("Multi"));
let para_count = children
.iter()
.filter(|b| matches!(b, Block::Paragraph(_)))
.count();
assert!(
para_count >= 2,
"expected at least 2 paragraphs, got {children:?}"
);
}
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_nested_inside_callout() {
let md = "> [!warning] Outer\n> Outer content.\n>\n> > [!tip] Inner\n> > Inner content.\n";
match first_block(md) {
Block::Callout {
kind: outer_kind,
children,
..
} => {
assert_eq!(outer_kind, CalloutKind::Warning);
let inner = children.iter().find_map(|b| match b {
Block::Callout { kind, title, .. } => Some((*kind, title.clone())),
_ => None,
});
let (inner_kind, inner_title) =
inner.expect("inner Block::Callout missing from outer's children");
assert_eq!(inner_kind, CalloutKind::Tip);
assert_eq!(inner_title.as_deref(), Some("Inner"));
}
other => panic!("expected outer Callout, got {other:?}"),
}
}
#[test]
fn plain_blockquote_without_marker_stays_blockquote() {
match first_block("> Just a quote.\n> More of the quote.\n") {
Block::BlockQuote(_) => {} other => panic!("expected BlockQuote, got {other:?}"),
}
}
#[test]
fn blockquote_with_text_starting_like_callout_but_unknown_kind_still_promotes() {
match first_block("> [!xyz] not a real kind\n> body\n") {
Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Note),
other => panic!("expected Callout fallback, got {other:?}"),
}
}
#[test]
fn callout_case_insensitive_kind() {
match first_block("> [!WARNING] Loud\n> body\n") {
Block::Callout { kind, .. } => assert_eq!(kind, CalloutKind::Warning),
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn callout_pending_alias_canonicalizes_to_todo() {
match first_block("> [!pending] Trailer video\n> Add when ready.\n") {
Block::Callout { kind, title, .. } => {
assert_eq!(kind, CalloutKind::Todo);
assert_eq!(title.as_deref(), Some("Trailer video"));
}
other => panic!("expected Callout, got {other:?}"),
}
}
#[test]
fn empty_input_yields_empty_document() {
let d = parse("");
assert!(d.blocks.is_empty());
}
#[test]
fn parses_h1_heading() {
match first_block("# Hello\n") {
Block::Heading {
level,
children,
id,
} => {
assert_eq!(level, 1);
assert_eq!(id.as_deref(), Some("hello"));
assert!(matches!(&children[0], Inline::Text(t) if t == "Hello"));
}
other => panic!("expected Heading, got {other:?}"),
}
}
#[test]
fn parses_h6_heading() {
match first_block("###### tiny\n") {
Block::Heading { level, .. } => assert_eq!(level, 6),
other => panic!("expected Heading, got {other:?}"),
}
}
#[test]
fn parses_paragraph_with_text() {
match first_block("hello world\n") {
Block::Paragraph(children) => {
let s: String = children
.iter()
.filter_map(|i| match i {
Inline::Text(t) => Some(t.as_str()),
_ => None,
})
.collect();
assert_eq!(s, "hello world");
}
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn parses_link_with_unresolved_url() {
match first_block("[Docs](docs/)\n") {
Block::Paragraph(children) => match &children[0] {
Inline::Link {
url,
title,
children,
is_wikilink,
} => {
assert!(url.is_unresolved());
match url {
Url::Unresolved(s) => assert_eq!(s, "docs/"),
_ => unreachable!(),
}
assert!(title.is_none());
assert!(!is_wikilink, "standard markdown link is not a wikilink");
assert!(matches!(&children[0], Inline::Text(t) if t == "Docs"));
}
other => panic!("expected Link, got {other:?}"),
},
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn parses_link_with_moss_resolved_prefix_unchanged() {
match first_block("[t](moss-resolved:foo.md)\n") {
Block::Paragraph(children) => match &children[0] {
Inline::Link {
url: Url::Unresolved(s),
..
} => assert_eq!(s, "moss-resolved:foo.md"),
other => panic!("expected unresolved Link, got {other:?}"),
},
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn parser_link_inherits_wikilink_from_pulldown_cmark() {
match first_block("[[wikilink-target]]\n") {
Block::Paragraph(children) => {
let link = children
.iter()
.find(|i| matches!(i, Inline::Link { .. }))
.expect("expected an Inline::Link from [[…]]");
match link {
Inline::Link { is_wikilink, .. } => {
assert!(
*is_wikilink,
"[[…]] must set is_wikilink: true on the typed AST"
);
}
_ => unreachable!(),
}
}
other => panic!("expected Paragraph, got {other:?}"),
}
match first_block("[text](href)\n") {
Block::Paragraph(children) => match &children[0] {
Inline::Link { is_wikilink, .. } => {
assert!(!is_wikilink, "[](…) must set is_wikilink: false");
}
_ => panic!("expected Link"),
},
_ => panic!("expected Paragraph"),
}
}
#[test]
fn parses_link_with_title() {
match first_block(r#"[t](u "the title")"#) {
Block::Paragraph(children) => match &children[0] {
Inline::Link { title, .. } => assert_eq!(title.as_deref(), Some("the title")),
other => panic!("expected Link, got {other:?}"),
},
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn parses_image_with_alt() {
match first_block("\n") {
Block::Figure { image, caption, .. } => {
match image {
Inline::Image {
src, alt, title, ..
} => {
assert!(src.is_unresolved());
assert_eq!(alt, "cat photo");
assert!(title.is_none());
}
other => panic!("expected Image inside Figure, got {other:?}"),
}
let cap = caption.expect("caption from alt text");
assert_eq!(cap.len(), 1);
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn parses_image_inside_paragraph_with_text() {
match first_block("see  here\n") {
Block::Paragraph(children) => {
let img = children
.iter()
.find(|i| matches!(i, Inline::Image { .. }))
.expect("expected Inline::Image among siblings");
match img {
Inline::Image { src, alt, .. } => {
assert!(src.is_unresolved());
assert_eq!(alt, "cat photo");
}
_ => unreachable!(),
}
}
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn parses_emphasis_and_strong() {
let para = parse("*em* and **strong**\n")
.blocks
.into_iter()
.next()
.unwrap();
match para {
Block::Paragraph(children) => {
let has_em = children.iter().any(|i| matches!(i, Inline::Emphasis(_)));
let has_strong = children.iter().any(|i| matches!(i, Inline::Strong(_)));
assert!(has_em, "missing Emphasis: {children:?}");
assert!(has_strong, "missing Strong: {children:?}");
}
_ => panic!("expected Paragraph"),
}
}
#[test]
fn parses_inline_code() {
match first_block("`some code`\n") {
Block::Paragraph(children) => {
assert!(matches!(&children[0], Inline::Code(c) if c == "some code"));
}
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn parses_unordered_list() {
match first_block("- one\n- two\n") {
Block::List { ordered, items, .. } => {
assert!(!ordered);
assert_eq!(items.len(), 2);
}
other => panic!("expected List, got {other:?}"),
}
}
#[test]
fn parser_handles_tight_list_items_with_inline_content() {
match first_block("- **bold** text\n- another item\n") {
Block::List { ordered, items, .. } => {
assert!(!ordered);
assert_eq!(items.len(), 2, "expected two items, got {items:?}");
let first_item = &items[0];
assert_eq!(
first_item.len(),
1,
"tight item should synthesize a single Paragraph, got {first_item:?}"
);
match &first_item[0] {
Block::Paragraph(inlines) => {
let has_strong = inlines.iter().any(|i| matches!(i, Inline::Strong(_)));
let has_text = inlines
.iter()
.any(|i| matches!(i, Inline::Text(t) if t.contains("text")));
assert!(
has_strong,
"expected Inline::Strong inside item, got {inlines:?}"
);
assert!(has_text, "expected ' text' Inline::Text, got {inlines:?}");
}
other => panic!("expected Paragraph inside tight item, got {other:?}"),
}
}
other => panic!("expected List, got {other:?}"),
}
}
#[test]
fn tight_list_items_with_links_preserved() {
match first_block("- [link](url)\n- \n") {
Block::List { items, .. } => {
assert_eq!(items.len(), 2);
let first = &items[0];
assert_eq!(
first.len(),
1,
"expected one Block::Paragraph, got {first:?}"
);
match &first[0] {
Block::Paragraph(inlines) => {
assert!(
inlines.iter().any(|i| matches!(i, Inline::Link { .. })),
"expected Inline::Link, got {inlines:?}"
);
}
other => panic!("expected Paragraph, got {other:?}"),
}
let second = &items[1];
match &second[0] {
Block::Paragraph(inlines) => {
assert!(
inlines.iter().any(|i| matches!(i, Inline::Image { .. })),
"expected Inline::Image, got {inlines:?}"
);
}
other => panic!("expected Paragraph, got {other:?}"),
}
}
other => panic!("expected List, got {other:?}"),
}
}
#[test]
fn loose_list_items_with_paragraphs_still_work() {
let md = "- first item\n\n- second item\n";
match first_block(md) {
Block::List { items, .. } => {
assert_eq!(items.len(), 2);
for item in &items {
assert_eq!(item.len(), 1, "expected one block per item");
assert!(
matches!(&item[0], Block::Paragraph(_)),
"expected Paragraph, got {:?}",
item[0]
);
}
}
other => panic!("expected List, got {other:?}"),
}
}
#[test]
fn tight_list_items_with_nested_list_preserve_structure() {
let md = "- first\n - nested\n";
match first_block(md) {
Block::List { items, .. } => {
assert_eq!(items.len(), 1);
let outer = &items[0];
assert!(
outer.iter().any(|b| matches!(b, Block::Paragraph(_))),
"expected outer item to carry a Paragraph for 'first', got {outer:?}"
);
assert!(
outer.iter().any(|b| matches!(b, Block::List { .. })),
"expected outer item to carry a nested List, got {outer:?}"
);
}
other => panic!("expected List, got {other:?}"),
}
}
#[test]
fn parses_ordered_list() {
match first_block("1. first\n2. second\n") {
Block::List { ordered, items, .. } => {
assert!(ordered);
assert_eq!(items.len(), 2);
}
other => panic!("expected List, got {other:?}"),
}
}
#[test]
fn parses_fenced_code_block_with_lang() {
match first_block("```rust\nfn main() {}\n```\n") {
Block::CodeBlock { lang, value } => {
assert_eq!(lang.as_deref(), Some("rust"));
assert!(value.contains("fn main"));
}
other => panic!("expected CodeBlock, got {other:?}"),
}
}
#[test]
fn parses_fenced_code_block_without_lang() {
match first_block("```\nbare\n```\n") {
Block::CodeBlock { lang, value } => {
assert!(lang.is_none());
assert!(value.contains("bare"));
}
other => panic!("expected CodeBlock, got {other:?}"),
}
}
#[test]
fn code_block_is_not_parsed_as_shortcode() {
let md = "```\n:::buttons\n[t](u)\n:::\n```\n";
match first_block(md) {
Block::CodeBlock { value, .. } => assert!(value.contains(":::buttons")),
other => panic!("expected CodeBlock, got {other:?}"),
}
}
#[test]
fn parses_blockquote() {
match first_block("> quoted\n") {
Block::BlockQuote(children) => {
assert!(!children.is_empty());
}
other => panic!("expected BlockQuote, got {other:?}"),
}
}
#[test]
fn parses_thematic_break() {
match first_block("---\n") {
Block::ThematicBreak => {}
_other => {
let d = parse("para\n\n---\n\nmore\n");
let has_break = d.blocks.iter().any(|b| matches!(b, Block::ThematicBreak));
assert!(
has_break,
"expected at least one ThematicBreak: {:?}",
d.blocks
);
}
}
}
#[test]
fn parses_table() {
let md = "| h1 | h2 |\n| --- | --- |\n| a | b |\n| c | d |\n";
match first_block(md) {
Block::Table { header, rows, .. } => {
assert_eq!(header.len(), 2);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].len(), 2);
}
other => panic!("expected Table, got {other:?}"),
}
}
#[test]
fn html_block_passes_through_as_other() {
match first_block("<div class=\"raw\">hi</div>\n\n") {
Block::Other(html) => assert!(html.contains("<div")),
other => panic!("expected Other, got {other:?}"),
}
}
#[test]
fn parses_multiple_blocks() {
let d = parse("# T\n\npara\n\n- li\n");
assert_eq!(d.blocks.len(), 3);
assert!(matches!(d.blocks[0], Block::Heading { .. }));
assert!(matches!(d.blocks[1], Block::Paragraph(_)));
assert!(matches!(d.blocks[2], Block::List { .. }));
}
#[test]
fn frontmatter_only_input_is_handled() {
let _ = parse("---\nfoo: bar\n---\n");
}
#[test]
fn link_inside_heading_is_preserved() {
match first_block("# [t](u)\n") {
Block::Heading { children, .. } => {
assert!(matches!(&children[0], Inline::Link { .. }));
}
other => panic!("expected Heading, got {other:?}"),
}
}
fn heading_id(md: &str) -> Option<String> {
let blocks = parse(md).blocks;
for block in &blocks {
if let Block::Heading { id, .. } = block {
return id.clone();
}
}
None
}
#[test]
fn heading_id_simple_phrase() {
assert_eq!(heading_id("## Mission\n"), Some("mission".to_string()));
}
#[test]
fn heading_id_spaces_become_hyphens() {
assert_eq!(
heading_id("# Getting Started\n"),
Some("getting-started".to_string())
);
}
#[test]
fn heading_id_with_emphasis_uses_text_content() {
assert_eq!(
heading_id("# Hello *world*\n"),
Some("hello-world".to_string())
);
}
#[test]
fn heading_id_with_strong_uses_text_content() {
assert_eq!(
heading_id("# Bold **stuff**\n"),
Some("bold-stuff".to_string())
);
}
#[test]
fn heading_id_with_inline_link_uses_link_text() {
assert_eq!(heading_id("# [Docs](url)\n"), Some("docs".to_string()));
}
#[test]
fn heading_id_with_inline_code_includes_code_payload() {
assert_eq!(
heading_id("# call `fn(x)`\n"),
Some("call-fn(x)".to_string())
);
}
#[test]
fn heading_id_with_inline_html_strips_html() {
let id = heading_id("# FAREWELL,<br>AND ERASE\n").expect("heading id");
assert!(!id.contains("br"), "got: {id}");
assert_eq!(id, "farewell,and-erase");
}
#[test]
fn heading_id_cjk_preserved() {
assert_eq!(heading_id("## 视频\n"), Some("视频".to_string()));
assert_eq!(heading_id("## 中文标题\n"), Some("中文标题".to_string()));
}
#[test]
fn heading_id_obsidian_strip_chars() {
assert_eq!(heading_id("# Note ^ref\n"), Some("note-ref".to_string()));
assert_eq!(heading_id("# A | B\n"), Some("a-b".to_string()));
}
#[test]
fn duplicate_headings_get_suffixed_ids() {
let md = "# Mission\n\n# Mission\n\n# Mission\n";
let doc = parse(md);
let ids: Vec<Option<String>> = doc
.blocks
.iter()
.filter_map(|b| match b {
Block::Heading { id, .. } => Some(id.clone()),
_ => None,
})
.collect();
assert_eq!(
ids,
vec![
Some("mission".to_string()),
Some("mission-1".to_string()),
Some("mission-2".to_string()),
]
);
}
#[test]
fn duplicate_suffix_descends_into_blockquote() {
let md = "# Notes\n\n> # Notes\n";
let doc = parse(md);
let mut found_ids: Vec<String> = Vec::new();
collect_heading_ids_recursive(&doc.blocks, &mut found_ids);
assert_eq!(found_ids, vec!["notes".to_string(), "notes-1".to_string()]);
}
fn collect_heading_ids_recursive(blocks: &[Block], out: &mut Vec<String>) {
for b in blocks {
match b {
Block::Heading { id, .. } => {
if let Some(s) = id {
out.push(s.clone());
}
}
Block::BlockQuote(children) | Block::Callout { children, .. } => {
collect_heading_ids_recursive(children, out);
}
Block::List { items, .. } => {
for item in items {
collect_heading_ids_recursive(item, out);
}
}
_ => {}
}
}
}
#[test]
fn heading_id_empty_text_yields_empty_slug() {
let md = "# ###\n";
let id = heading_id(md);
assert_eq!(id, Some(String::new()));
}
#[test]
fn link_inside_emphasis_unwraps_correctly() {
match first_block("*[t](u)*\n") {
Block::Paragraph(children) => match &children[0] {
Inline::Emphasis(inner) => {
assert!(matches!(&inner[0], Inline::Link { .. }));
}
other => panic!("expected Emphasis, got {other:?}"),
},
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn image_only_paragraph_promotes_to_figure() {
match first_block("\n") {
Block::Figure { image, caption, .. } => {
match image {
Inline::Image { src, alt, .. } => {
assert!(src.is_unresolved());
assert_eq!(alt, "A logo");
}
other => panic!("expected Image inside Figure, got {other:?}"),
}
let cap = caption.expect("caption from alt text");
assert_eq!(cap.len(), 1);
assert!(matches!(&cap[0], Inline::Text(t) if t == "A logo"));
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn image_only_paragraph_with_empty_alt_stays_as_paragraph() {
match first_block("\n") {
Block::Paragraph(children) => {
assert_eq!(children.len(), 1);
match &children[0] {
Inline::Image { alt, .. } => assert_eq!(alt, ""),
other => panic!("expected Image inside Paragraph, got {other:?}"),
}
}
other => panic!("empty-alt image-only paragraph must stay as Paragraph, got {other:?}"),
}
}
#[test]
fn image_with_whitespace_text_still_promotes_to_figure() {
let md = " \n";
match first_block(md) {
Block::Figure { image, .. } => assert!(matches!(image, Inline::Image { .. })),
Block::Paragraph(_) => {}
other => panic!("expected Figure or Paragraph, got {other:?}"),
}
}
#[test]
fn image_with_caption_text_does_not_promote() {
match first_block(" plain caption text\n") {
Block::Paragraph(children) => {
assert!(children.iter().any(|i| matches!(i, Inline::Image { .. })));
assert!(
children
.iter()
.any(|i| matches!(i, Inline::Text(t) if t.contains("plain"))),
"expected sibling Text to remain, got {children:?}"
);
}
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn image_with_emphasis_sibling_does_not_promote() {
match first_block(" *caption*\n") {
Block::Paragraph(children) => {
assert!(children.iter().any(|i| matches!(i, Inline::Image { .. })));
assert!(
children.iter().any(|i| matches!(i, Inline::Emphasis(_))),
"expected Emphasis to remain, got {children:?}"
);
}
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn two_images_in_one_paragraph_do_not_promote() {
match first_block(" \n") {
Block::Paragraph(children) => {
let img_count = children
.iter()
.filter(|i| matches!(i, Inline::Image { .. }))
.count();
assert_eq!(img_count, 2);
}
other => panic!("expected Paragraph (two images), got {other:?}"),
}
}
#[test]
fn standard_image_percent_promotes_with_width() {
match first_block("\n") {
Block::Figure { width, caption, .. } => {
assert_eq!(width.as_deref(), Some("55%"));
let cap = caption.expect("caption from remaining alt");
assert!(matches!(cap.as_slice(), [Inline::Text(t)] if t == "alt"));
}
other => panic!("expected a Figure, got {other:?}"),
}
}
#[test]
fn standard_image_percent_empty_alt_still_promotes() {
match first_block("\n") {
Block::Figure { width, caption, .. } => {
assert_eq!(width.as_deref(), Some("55%"));
assert!(
caption.is_none() || matches!(caption.as_deref(), Some([])),
"empty-alt-with-width figure must not carry a caption: {caption:?}"
);
}
other => panic!("expected a Figure even with empty alt when width present, got {other:?}"),
}
}
#[test]
fn standard_image_no_width_unchanged() {
match first_block("\n") {
Block::Figure { width, .. } => assert_eq!(width, None),
other => panic!("expected a Figure, got {other:?}"),
}
}
#[test]
fn plain_paragraph_still_parses_as_paragraph() {
match first_block("just some prose\n") {
Block::Paragraph(_) => {}
other => panic!("expected Paragraph, got {other:?}"),
}
}
use super::super::shortcode::Shortcode;
#[test]
fn parses_subscribe_block_into_typed_shortcode() {
let md = r#":::subscribe {placeholder="you@domain.com" button="Sign me up"}
:::
"#;
let doc = parse(md);
let mut found: Option<&Shortcode> = None;
for block in &doc.blocks {
if let Block::Shortcode(sc) = block {
found = Some(sc);
break;
}
}
let sc = found.expect("expected Block::Shortcode");
match sc {
Shortcode::Subscribe(args) => {
assert_eq!(args.placeholder.as_deref(), Some("you@domain.com"));
assert_eq!(args.button.as_deref(), Some("Sign me up"));
}
other => panic!("expected Subscribe, got {other:?}"),
}
}
#[test]
fn subscribe_block_does_not_leave_sentinel_in_other_block() {
let md = ":::subscribe\n:::\n";
let doc = parse(md);
for block in &doc.blocks {
if let Block::Other(html) = block {
assert!(
!html.contains("MOSS_SHORTCODE"),
"unsubstituted sentinel remained in AST: {html:?}"
);
}
}
}
#[test]
fn subscribe_inside_paragraph_text_is_not_extracted() {
let md = "Read more about :::subscribe in the docs.\n";
let doc = parse(md);
for block in &doc.blocks {
assert!(
!matches!(block, Block::Shortcode(_)),
"`:::subscribe` inline-text was wrongly extracted as a shortcode"
);
}
}
#[test]
fn subscribe_block_alongside_other_content_preserves_order() {
let md = "# H\n\nfirst para\n\n:::subscribe\ndescription: d\n:::\n\nlast para\n";
let doc = parse(md);
let kinds: Vec<&'static str> = doc
.blocks
.iter()
.map(|b| match b {
Block::Heading { .. } => "h",
Block::Paragraph(_) => "p",
Block::Shortcode(_) => "sc",
_ => "x",
})
.collect();
assert_eq!(kinds, vec!["h", "p", "sc", "p"]);
}
#[test]
fn parse_default_config_keeps_block_meta_empty() {
let doc = parse("# H1\n\npara one\n\npara two\n");
assert_eq!(doc.blocks.len(), 3);
assert_eq!(doc.block_meta.len(), doc.blocks.len());
for meta in &doc.block_meta {
assert!(
meta.source_line.is_none(),
"default parse should not populate source_line: {meta:?}"
);
}
}
#[test]
fn parse_with_source_lines_assigns_1_based_line_numbers() {
let md = "# H1\n\npara on line 3\n\n## H2 on line 5\n\npara on line 7\n";
let config = ParseConfig {
emit_source_lines: true,
implicit_figure: true,
source_line_offset: 0,
math: false,
};
let doc = parse_with_config(md, &config);
assert_eq!(doc.blocks.len(), 4);
assert_eq!(doc.block_meta.len(), 4);
assert_eq!(doc.block_meta[0].source_line, Some(1), "H1 on line 1");
assert_eq!(doc.block_meta[1].source_line, Some(3), "P on line 3");
assert_eq!(doc.block_meta[2].source_line, Some(5), "H2 on line 5");
assert_eq!(doc.block_meta[3].source_line, Some(7), "P on line 7");
}
#[test]
fn source_line_offset_is_applied_additively() {
let md = "# H1\n\npara on line 3\n";
let config = ParseConfig {
emit_source_lines: true,
implicit_figure: true,
source_line_offset: 7,
math: false,
};
let doc = parse_with_config(md, &config);
assert_eq!(
doc.block_meta[0].source_line,
Some(8),
"H1 body-line 1 + offset 7"
);
assert_eq!(
doc.block_meta[1].source_line,
Some(10),
"P body-line 3 + offset 7"
);
}
#[test]
fn source_lines_not_collapsed_across_multiline_shortcode() {
let md = "# Title\n\n:::grid 3\n[\n\n](/x)\n+++\n[\n\n](/y)\n:::\n\n## After\n";
let config = ParseConfig {
emit_source_lines: true,
implicit_figure: true,
source_line_offset: 0,
math: false,
};
let doc = parse_with_config(md, &config);
let last = doc
.block_meta
.last()
.expect("at least one block")
.source_line;
assert_eq!(
last,
Some(13),
"heading after a multi-line grid must keep its real line 13, not a collapsed line"
);
}
#[test]
fn parse_with_source_lines_lists_and_blockquotes() {
let md = "- item one\n- item two\n\n> quote on line 4\n";
let config = ParseConfig {
emit_source_lines: true,
implicit_figure: true,
source_line_offset: 0,
math: false,
};
let doc = parse_with_config(md, &config);
assert_eq!(doc.blocks.len(), 2);
assert_eq!(doc.block_meta[0].source_line, Some(1), "ul on line 1");
assert_eq!(doc.block_meta[1].source_line, Some(4), "bq on line 4");
}
#[test]
fn parse_with_source_lines_populates_item_lines_on_list() {
let md = "- one\n- two\n- three\n";
let config = ParseConfig {
emit_source_lines: true,
implicit_figure: true,
source_line_offset: 0,
math: false,
};
let doc = parse_with_config(md, &config);
assert_eq!(doc.blocks.len(), 1);
match &doc.blocks[0] {
Block::List {
items,
item_source_lines,
..
} => {
assert_eq!(items.len(), 3);
assert_eq!(
item_source_lines.len(),
3,
"item_source_lines must be parallel to items"
);
assert_eq!(item_source_lines[0], Some(1));
assert_eq!(item_source_lines[1], Some(2));
assert_eq!(item_source_lines[2], Some(3));
}
other => panic!("expected List, got {other:?}"),
}
}
#[test]
fn parse_default_config_leaves_item_source_lines_empty() {
let doc = parse("- one\n- two\n");
assert_eq!(doc.blocks.len(), 1);
match &doc.blocks[0] {
Block::List {
item_source_lines, ..
} => {
assert!(
item_source_lines.is_empty(),
"default config must NOT populate item_source_lines (publish builds): {item_source_lines:?}"
);
}
other => panic!("expected List, got {other:?}"),
}
}
#[test]
fn parse_with_source_lines_populates_row_lines_on_table() {
let md = "| h1 | h2 |\n| --- | --- |\n| a | b |\n| c | d |\n| e | f |\n";
let config = ParseConfig {
emit_source_lines: true,
implicit_figure: true,
source_line_offset: 0,
math: false,
};
let doc = parse_with_config(md, &config);
assert_eq!(doc.blocks.len(), 1);
match &doc.blocks[0] {
Block::Table {
rows,
header_source_line,
row_source_lines,
..
} => {
assert_eq!(rows.len(), 3);
assert_eq!(*header_source_line, Some(1), "header tr line");
assert_eq!(
row_source_lines.len(),
3,
"row_source_lines must be parallel to rows"
);
assert_eq!(row_source_lines[0], Some(3));
assert_eq!(row_source_lines[1], Some(4));
assert_eq!(row_source_lines[2], Some(5));
}
other => panic!("expected Table, got {other:?}"),
}
}
#[test]
fn parse_default_config_leaves_row_source_lines_empty() {
let md = "| h1 | h2 |\n| --- | --- |\n| a | b |\n";
let doc = parse(md);
assert_eq!(doc.blocks.len(), 1);
match &doc.blocks[0] {
Block::Table {
header_source_line,
row_source_lines,
..
} => {
assert!(header_source_line.is_none());
assert!(row_source_lines.is_empty());
}
other => panic!("expected Table, got {other:?}"),
}
}
#[test]
fn parse_ordered_list_start_3_captures_start_number() {
let doc = parse("3. foo\n4. bar\n");
assert_eq!(doc.blocks.len(), 1);
match &doc.blocks[0] {
Block::List {
ordered,
start,
items,
..
} => {
assert!(ordered, "ordered list");
assert_eq!(*start, Some(3), "explicit start number captured");
assert_eq!(items.len(), 2);
}
other => panic!("expected ordered List, got {other:?}"),
}
}
#[test]
fn parse_ordered_list_default_start_collapses_to_none() {
let doc = parse("1. foo\n2. bar\n");
assert_eq!(doc.blocks.len(), 1);
match &doc.blocks[0] {
Block::List { ordered, start, .. } => {
assert!(ordered);
assert!(
start.is_none(),
"implicit start=1 must collapse to None, got {start:?}"
);
}
other => panic!("expected ordered List, got {other:?}"),
}
}
#[test]
fn parse_unordered_list_has_no_start() {
let doc = parse("- foo\n- bar\n");
assert_eq!(doc.blocks.len(), 1);
match &doc.blocks[0] {
Block::List { ordered, start, .. } => {
assert!(!ordered, "unordered list");
assert!(
start.is_none(),
"unordered list must have start=None, got {start:?}"
);
}
other => panic!("expected unordered List, got {other:?}"),
}
}
#[test]
fn parse_with_source_lines_handles_list_after_blank_line_offset() {
let md = "intro paragraph\n\n- item on line 3\n- item on line 4\n";
let config = ParseConfig {
emit_source_lines: true,
implicit_figure: true,
source_line_offset: 0,
math: false,
};
let doc = parse_with_config(md, &config);
assert_eq!(doc.blocks.len(), 2);
match &doc.blocks[1] {
Block::List {
item_source_lines, ..
} => {
assert_eq!(item_source_lines.len(), 2);
assert_eq!(item_source_lines[0], Some(3));
assert_eq!(item_source_lines[1], Some(4));
}
other => panic!("expected List as second block, got {other:?}"),
}
}
#[test]
fn parse_implicit_figure_default_promotes_image_only_paragraph() {
let doc = parse("\n");
assert_eq!(doc.blocks.len(), 1);
assert!(
matches!(doc.blocks[0], Block::Figure { .. }),
"default config (implicit_figure=true) should promote: got {:?}",
doc.blocks[0]
);
}
#[test]
fn parse_implicit_figure_off_leaves_image_paragraph_unpromoted() {
let config = ParseConfig {
emit_source_lines: false,
implicit_figure: false,
source_line_offset: 0,
math: false,
};
let doc = parse_with_config("\n", &config);
assert_eq!(doc.blocks.len(), 1);
match &doc.blocks[0] {
Block::Paragraph(inlines) => {
assert!(matches!(inlines[0], Inline::Image { .. }));
}
other => panic!("expected Paragraph with image, got {other:?}"),
}
}
#[test]
fn implicit_figure_caption_preserves_inline_markup() {
let block = first_block("\n");
match block {
Block::Figure { caption, image, .. } => {
let cap = caption.expect("caption must be present");
assert!(
cap.iter().any(|i| matches!(i, Inline::Emphasis(_))),
"caption must carry a typed Emphasis node, got {cap:?}"
);
assert!(
!cap.iter().any(|i| matches!(i, Inline::Text(t) if t.contains('*'))),
"caption must not contain raw `*` markers, got {cap:?}"
);
match image {
Inline::Image { alt, .. } => {
assert_eq!(
alt, "before em after",
"alt attribute must stay flat plain-text source"
);
}
other => panic!("expected Image, got {other:?}"),
}
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn implicit_figure_caption_carries_link_and_math_nodes() {
let config = ParseConfig {
emit_source_lines: false,
implicit_figure: true,
source_line_offset: 0,
math: true,
};
let doc = parse_with_config(" and $x^2$ end](img.png)\n", &config);
match doc.blocks.into_iter().next().expect("one block") {
Block::Figure { caption, image, .. } => {
let cap = caption.expect("caption must be present");
assert!(
cap.iter().any(|i| matches!(i, Inline::Link { .. })),
"caption must carry a typed Link node, got {cap:?}"
);
assert!(
cap.iter().any(|i| matches!(
i,
Inline::Other(html) if super::super::math_text::math_node_parts(html).is_some()
)),
"caption must carry a typed math node, got {cap:?}"
);
match image {
Inline::Image { alt, .. } => {
assert_eq!(
alt, "a link and $x^2$ end",
"alt must stay flat: link label inlined, math as source"
);
}
other => panic!("expected Image, got {other:?}"),
}
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn implicit_figure_caption_only_math_is_a_math_node() {
let config = ParseConfig {
emit_source_lines: false,
implicit_figure: true,
source_line_offset: 0,
math: true,
};
let doc = parse_with_config("\n", &config);
match doc.blocks.into_iter().next().expect("one block") {
Block::Figure { caption, image, .. } => {
let cap = caption.expect("caption must be present");
assert!(
cap.iter().any(|i| matches!(
i,
Inline::Other(html) if super::super::math_text::math_node_parts(html).is_some()
)),
"math-only caption must carry a math node, got {cap:?}"
);
match image {
Inline::Image { alt, .. } => {
assert_eq!(alt, "$E=mc^2$", "alt must be the math source verbatim");
}
other => panic!("expected Image, got {other:?}"),
}
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn implicit_figure_empty_alt_still_yields_no_caption() {
let doc = parse("\n");
assert!(
!matches!(doc.blocks.first(), Some(Block::Figure { .. })),
"empty-alt image must not promote to a figure: {:?}",
doc.blocks
);
}
#[test]
fn line_lookup_offset_zero_is_line_one() {
let lookup = LineLookup::build("hello\nworld\n", 0);
assert_eq!(lookup.line_at(0), 1);
}
#[test]
fn line_lookup_after_first_newline_is_line_two() {
let lookup = LineLookup::build("hello\nworld\n", 0);
assert_eq!(lookup.line_at(6), 2);
}
#[test]
fn line_lookup_handles_multiline_block_starts() {
let lookup = LineLookup::build("line1\nline2\nline3\n", 0);
assert_eq!(lookup.line_at(0), 1, "byte 0 → line 1");
assert_eq!(lookup.line_at(6), 2, "byte 6 → line 2");
assert_eq!(lookup.line_at(12), 3, "byte 12 → line 3");
}
#[test]
fn line_lookup_empty_source() {
let lookup = LineLookup::build("", 0);
assert_eq!(lookup.line_at(0), 1, "empty source still has line 1");
}
#[test]
fn wikilink_image_percent_no_graph_promotes_with_width() {
let block = first_block("![[pic.jpg|55%]]\n");
match block {
Block::Figure { width, caption, .. } => {
assert_eq!(width.as_deref(), Some("55%"));
assert!(caption.is_none(), "percent must not become a caption");
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn wikilink_image_percent_with_caption_no_graph() {
let block = first_block("![[pic.jpg|My cap|55%]]\n");
match block {
Block::Figure { width, caption, .. } => {
assert_eq!(width.as_deref(), Some("55%"));
let cap = caption.as_ref().expect("caption must be present");
assert!(
matches!(cap.as_slice(), [Inline::Text(t)] if t == "My cap"),
"caption should be the non-width segment, got {cap:?}"
);
}
other => panic!("expected Figure, got {other:?}"),
}
}
#[test]
fn wikilink_video_percent_stays_paragraph() {
let block = first_block("![[clip.mov|77%]]\n");
match block {
Block::Paragraph(inlines) => assert!(
matches!(
inlines.as_slice(),
[Inline::Image {
is_wikilink: true,
..
}]
),
"paragraph must hold the lone wikilink image, got {inlines:?}"
),
other => panic!("video embed must stay Paragraph for dispatch, got {other:?}"),
}
}
#[test]
fn wikilink_video_box_sizing_stays_paragraph() {
let block = first_block("![[clip.mov|640x360]]\n");
assert!(
matches!(block, Block::Paragraph(_)),
"expected Paragraph, got {block:?}"
);
}
#[test]
fn wikilink_pdf_alias_stays_paragraph() {
let block = first_block("![[report.pdf|80%]]\n");
assert!(
matches!(block, Block::Paragraph(_)),
"expected Paragraph, got {block:?}"
);
}
#[test]
fn wikilink_extensionless_stays_paragraph() {
let block = first_block("![[draft|55%]]\n");
assert!(
matches!(block, Block::Paragraph(_)),
"expected Paragraph, got {block:?}"
);
}
#[test]
fn wikilink_uppercase_image_ext_still_promotes() {
let block = first_block("![[photo.JPG|55%]]\n");
assert!(
matches!(block, Block::Figure { .. }),
"expected Figure, got {block:?}"
);
}
}