pub mod post_pass;
pub use post_pass::{
annotate_list_blanks, attach_trailing_tui_columns_comments, is_closing_fence,
parse_opening_fence, promote_diagram_code_blocks, promote_html_comments,
promote_image_paragraphs,
};
use std::ops::Range;
use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
use super::ast::{inlines_to_plain, Block, Inline, ListItem};
use super::parse_offsets;
pub fn parse(text: &str) -> Vec<Block> {
let (mut blocks, ranges) = parse_raw_with_ranges(text);
annotate_list_blanks(&mut blocks, &ranges, text);
promote_html_comments(&mut blocks);
attach_trailing_tui_columns_comments(&mut blocks);
promote_image_paragraphs(&mut blocks, None);
blocks
}
#[allow(dead_code)]
pub fn parse_raw(text: &str) -> Vec<Block> {
let parser = Parser::new_ext(text, parse_offsets::PARSE_OPTIONS);
let mut events = parser.peekable();
parse_blocks(&mut events)
}
pub fn parse_raw_with_ranges(text: &str) -> (Vec<Block>, Vec<Range<usize>>) {
let mut tracker = parse_offsets::RangeTracker::new(|_| true);
let mut events = Parser::new_ext(text, parse_offsets::PARSE_OPTIONS)
.into_offset_iter()
.map(|(event, byte_range)| {
tracker.observe(text, &event, &byte_range);
event
})
.peekable();
let blocks = parse_blocks(&mut events);
while events.next().is_some() {}
drop(events);
(blocks, tracker.into_ranges())
}
fn parse_blocks<'a, I>(events: &mut std::iter::Peekable<I>) -> Vec<Block>
where
I: Iterator<Item = Event<'a>>,
{
let mut blocks = Vec::new();
loop {
match events.peek() {
None | Some(Event::End(_)) => break,
Some(Event::Start(Tag::Paragraph)) => {
if let Some(b) = parse_paragraph_block(events) {
blocks.push(b);
}
}
Some(Event::Start(Tag::Heading { .. })) => match parse_heading_block(events) {
Some(b) => blocks.push(b),
None => break,
},
Some(Event::Start(Tag::BlockQuote(_))) => {
blocks.push(parse_blockquote_block(events));
}
Some(Event::Start(Tag::CodeBlock(_))) => {
blocks.push(parse_code_block(events));
}
Some(Event::Start(Tag::List(_))) => {
blocks.push(parse_list_block(events));
}
Some(Event::Start(Tag::Table(_))) => {
blocks.push(parse_table_block(events));
}
Some(Event::Rule) => {
events.next();
blocks.push(Block::HorizontalRule);
}
Some(Event::Start(Tag::HtmlBlock)) => {
blocks.push(parse_html_block(events));
}
Some(Event::Html(_)) => {
if let Some(Event::Html(html)) = events.next() {
blocks.push(Block::Html(html.into_string()));
}
}
Some(Event::Start(Tag::FootnoteDefinition(_))) => {
blocks.push(parse_footnote_definition_block(events));
}
Some(Event::Text(_))
| Some(Event::Code(_))
| Some(Event::SoftBreak)
| Some(Event::HardBreak)
| Some(Event::Start(Tag::Emphasis))
| Some(Event::Start(Tag::Strong))
| Some(Event::Start(Tag::Strikethrough))
| Some(Event::Start(Tag::Link { .. }))
| Some(Event::Start(Tag::Image { .. })) => {
let inlines = parse_inlines(events);
if !inlines.is_empty() {
blocks.push(Block::Paragraph { inlines });
}
}
_ => {
events.next();
}
}
}
blocks
}
fn parse_paragraph_block<'a, I>(events: &mut std::iter::Peekable<I>) -> Option<Block>
where
I: Iterator<Item = Event<'a>>,
{
events.next();
let inlines = parse_inlines(events);
consume_end(events);
if inlines.is_empty() {
None
} else {
Some(Block::Paragraph { inlines })
}
}
fn parse_heading_block<'a, I>(events: &mut std::iter::Peekable<I>) -> Option<Block>
where
I: Iterator<Item = Event<'a>>,
{
let level = match events.next()? {
Event::Start(Tag::Heading { level, .. }) => level,
_ => return None,
};
let inlines = parse_inlines(events);
consume_end(events);
Some(Block::Heading { level, inlines })
}
fn parse_blockquote_block<'a, I>(events: &mut std::iter::Peekable<I>) -> Block
where
I: Iterator<Item = Event<'a>>,
{
events.next();
let inner = parse_blocks(events);
consume_end(events);
Block::BlockQuote { blocks: inner }
}
fn parse_footnote_definition_block<'a, I>(events: &mut std::iter::Peekable<I>) -> Block
where
I: Iterator<Item = Event<'a>>,
{
let label = match events.next() {
Some(Event::Start(Tag::FootnoteDefinition(l))) => l.into_string(),
_ => String::new(),
};
let inner = parse_blocks(events);
consume_end(events);
Block::FootnoteDefinition {
label,
blocks: inner,
}
}
fn parse_code_block<'a, I>(events: &mut std::iter::Peekable<I>) -> Block
where
I: Iterator<Item = Event<'a>>,
{
let (language, fenced) = match events.next() {
Some(Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang)))) => {
let s = lang.as_ref().trim().to_owned();
(if s.is_empty() { None } else { Some(s) }, true)
}
Some(Event::Start(Tag::CodeBlock(CodeBlockKind::Indented))) => (None, false),
_ => (None, false),
};
let content = collect_text_until_end(events);
Block::CodeBlock {
language,
content,
fenced,
}
}
fn parse_list_block<'a, I>(events: &mut std::iter::Peekable<I>) -> Block
where
I: Iterator<Item = Event<'a>>,
{
let start = match events.next() {
Some(Event::Start(Tag::List(s))) => s,
_ => None,
};
let items = parse_list_items(events);
consume_end(events);
Block::List {
ordered: start.is_some(),
start,
items,
}
}
fn parse_table_block<'a, I>(events: &mut std::iter::Peekable<I>) -> Block
where
I: Iterator<Item = Event<'a>>,
{
events.next();
let (headers, rows, col_count) = parse_table(events);
consume_end(events);
Block::Table {
col_count,
headers,
rows,
user_widths: None,
}
}
fn parse_html_block<'a, I>(events: &mut std::iter::Peekable<I>) -> Block
where
I: Iterator<Item = Event<'a>>,
{
events.next();
let mut body = String::new();
loop {
match events.peek() {
None => break,
Some(Event::End(TagEnd::HtmlBlock)) => {
events.next();
break;
}
_ => match events.next() {
Some(Event::Html(h)) => body.push_str(&h),
Some(Event::Text(t)) => body.push_str(&t),
Some(_) | None => {}
},
}
}
Block::Html(body)
}
fn parse_list_items<'a, I>(events: &mut std::iter::Peekable<I>) -> Vec<ListItem>
where
I: Iterator<Item = Event<'a>>,
{
let mut items = Vec::new();
loop {
match events.peek() {
None | Some(Event::End(_)) => break,
Some(Event::Start(Tag::Item)) => {
events.next();
let mut task: Option<bool> = None;
if let Some(Event::TaskListMarker(_)) = events.peek() {
if let Some(Event::TaskListMarker(checked)) = events.next() {
task = Some(checked);
}
}
let mut paragraph_consumed = false;
if task.is_none() && matches!(events.peek(), Some(Event::Start(Tag::Paragraph))) {
events.next(); paragraph_consumed = true;
if let Some(Event::TaskListMarker(_)) = events.peek() {
if let Some(Event::TaskListMarker(checked)) = events.next() {
task = Some(checked);
}
}
}
let mut blocks: Vec<Block> = Vec::new();
if paragraph_consumed {
let inlines = parse_inlines(events);
consume_end(events); if !inlines.is_empty() {
blocks.push(Block::Paragraph { inlines });
}
blocks.extend(parse_blocks(events));
} else {
blocks = parse_blocks(events);
}
consume_end(events); items.push(ListItem {
blocks,
task,
blank_lines_before: 0,
});
}
_ => {
events.next(); }
}
}
items
}
type ParsedTable = (Vec<Vec<Inline>>, Vec<Vec<Vec<Inline>>>, usize);
fn parse_table<'a, I>(events: &mut std::iter::Peekable<I>) -> ParsedTable
where
I: Iterator<Item = Event<'a>>,
{
let mut headers: Vec<Vec<Inline>> = Vec::new();
let mut rows: Vec<Vec<Vec<Inline>>> = Vec::new();
let mut col_count = 0;
loop {
match events.peek() {
None | Some(Event::End(TagEnd::Table)) => break,
Some(Event::Start(Tag::TableHead)) => {
events.next();
headers = parse_table_row(events);
col_count = headers.len();
consume_end(events); }
Some(Event::Start(Tag::TableRow)) => {
events.next();
let row = parse_table_row(events);
consume_end(events); rows.push(row);
}
_ => {
events.next();
}
}
}
(headers, rows, col_count)
}
fn parse_table_row<'a, I>(events: &mut std::iter::Peekable<I>) -> Vec<Vec<Inline>>
where
I: Iterator<Item = Event<'a>>,
{
let mut cells = Vec::new();
loop {
match events.peek() {
None | Some(Event::End(TagEnd::TableHead)) | Some(Event::End(TagEnd::TableRow)) => {
break
}
Some(Event::Start(Tag::TableCell)) => {
events.next();
let inlines = parse_inlines(events);
consume_end(events); cells.push(inlines);
}
_ => {
events.next();
}
}
}
cells
}
fn parse_highlight_in_text(text: &str) -> Vec<Inline> {
let mut result = Vec::new();
let mut rest = text;
loop {
match rest.find("==") {
None => break,
Some(start) => {
let after_open = &rest[start + 2..];
match after_open.find("==") {
None => break, Some(rel_end) => {
if start > 0 {
result.push(Inline::Text(rest[..start].to_owned()));
}
let inner = &after_open[..rel_end];
result.push(Inline::Highlight(vec![Inline::Text(inner.to_owned())]));
rest = &after_open[rel_end + 2..];
}
}
}
}
}
if !rest.is_empty() {
result.push(Inline::Text(rest.to_owned()));
}
result
}
fn parse_inlines<'a, I>(events: &mut std::iter::Peekable<I>) -> Vec<Inline>
where
I: Iterator<Item = Event<'a>>,
{
let mut inlines = Vec::new();
loop {
match events.peek() {
None | Some(Event::End(_)) => break,
Some(Event::Start(Tag::Paragraph))
| Some(Event::Start(Tag::Heading { .. }))
| Some(Event::Start(Tag::BlockQuote(_)))
| Some(Event::Start(Tag::CodeBlock(_)))
| Some(Event::Start(Tag::List(_)))
| Some(Event::Rule) => break,
_ => {}
}
let event = match events.next() {
Some(e) => e,
None => break,
};
match event {
Event::Text(text) => inlines.extend(parse_highlight_in_text(&text)),
Event::Code(code) => inlines.push(Inline::Code(code.into_string())),
Event::SoftBreak => inlines.push(Inline::SoftBreak),
Event::HardBreak => inlines.push(Inline::HardBreak),
Event::Html(html) | Event::InlineHtml(html) => {
let s = html.into_string();
if post_pass::is_html_comment_only(&s) {
inlines.push(Inline::HtmlComment(s));
} else {
inlines.push(Inline::Text(s));
}
}
Event::Start(Tag::Emphasis) => {
let inner = parse_inlines(events);
consume_end(events);
inlines.push(Inline::Italic(inner));
}
Event::Start(Tag::Strong) => {
let inner = parse_inlines(events);
consume_end(events);
inlines.push(Inline::Bold(inner));
}
Event::Start(Tag::Strikethrough) => {
let inner = parse_inlines(events);
consume_end(events);
inlines.push(Inline::Strikethrough(inner));
}
Event::Start(Tag::Link {
dest_url, title, ..
}) => {
let text = parse_inlines(events);
consume_end(events);
let title_str = title.as_ref().to_owned();
inlines.push(Inline::Link {
text,
url: dest_url.into_string(),
title: if title_str.is_empty() {
None
} else {
Some(title_str)
},
});
}
Event::Start(Tag::Image { dest_url, .. }) => {
let alt_inlines = parse_inlines(events);
consume_end(events);
let alt = inlines_to_plain(&alt_inlines);
inlines.push(Inline::Image {
alt,
url: dest_url.into_string(),
});
}
Event::FootnoteReference(label) => {
inlines.push(Inline::FootnoteReference {
label: label.into_string(),
});
}
Event::TaskListMarker(_) => {}
_ => {}
}
}
inlines
}
fn consume_end<'a, I>(events: &mut std::iter::Peekable<I>)
where
I: Iterator<Item = Event<'a>>,
{
if matches!(events.peek(), Some(Event::End(_))) {
events.next();
}
}
fn collect_text_until_end<'a, I>(events: &mut std::iter::Peekable<I>) -> String
where
I: Iterator<Item = Event<'a>>,
{
let mut text = String::new();
loop {
match events.peek() {
None | Some(Event::End(_)) => break,
_ => {}
}
if let Some(Event::Text(t)) = events.next() {
text.push_str(&t);
}
}
consume_end(events);
text
}
#[cfg(test)]
mod tests {
use super::*;
use crate::markdown::ast::{Block, Inline};
use pulldown_cmark::HeadingLevel;
#[test]
fn merged_parse_matches_two_pass_parse() {
let src = "# Title\n\nA paragraph with **bold**.\n\n- item one\n- item two\n\n\
```rust\nfn x() {}\n```\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n\
> quoted\n\n---\n\nRef.[^1]\n\n[^1]: A note.\n\n<!-- a comment -->\n";
let (blocks, ranges) = parse_raw_with_ranges(src);
assert_eq!(blocks, parse_raw(src));
assert_eq!(ranges, parse_offsets::top_level_block_ranges(src));
assert_eq!(blocks.len(), ranges.len(), "blocks↔ranges must stay 1:1");
}
#[test]
fn parse_heading() {
let blocks = parse("# Hello\n");
assert_eq!(
blocks,
vec![Block::Heading {
level: HeadingLevel::H1,
inlines: vec![Inline::Text("Hello".into())],
}]
);
}
#[test]
fn setext_h2_is_heading_not_rule() {
let blocks = parse("H2 text\n---\n");
eprintln!("setext H2 blocks: {:?}", blocks);
assert!(
matches!(
&blocks[0],
Block::Heading {
level: HeadingLevel::H2,
..
}
),
"expected H2 heading, got: {:?}",
blocks
);
}
#[test]
fn parse_paragraph() {
let blocks = parse("Hello world\n");
assert!(matches!(&blocks[0], Block::Paragraph { inlines } if !inlines.is_empty()));
}
#[test]
fn parse_bold_and_italic() {
let blocks = parse("**bold** and *italic*\n");
if let Block::Paragraph { inlines } = &blocks[0] {
assert!(inlines.iter().any(|i| matches!(i, Inline::Bold(_))));
assert!(inlines.iter().any(|i| matches!(i, Inline::Italic(_))));
} else {
panic!("Expected paragraph");
}
}
#[test]
fn parse_code_span() {
let blocks = parse("`code`\n");
if let Block::Paragraph { inlines } = &blocks[0] {
assert!(inlines.iter().any(|i| matches!(i, Inline::Code(_))));
} else {
panic!("Expected paragraph");
}
}
#[test]
fn parse_fenced_code_block() {
let blocks = parse("```rust\nfn main() {}\n```\n");
assert!(matches!(
&blocks[0],
Block::CodeBlock { language: Some(lang), .. } if lang == "rust"
));
}
#[test]
fn parse_horizontal_rule() {
let blocks = parse("---\n");
assert!(blocks.contains(&Block::HorizontalRule));
}
#[test]
fn parse_tight_unordered_list() {
let blocks = parse("- one\n- two\n");
match &blocks[0] {
Block::List { ordered, items, .. } => {
assert!(!ordered);
assert_eq!(items.len(), 2);
}
other => panic!("Expected List, got: {:?}", other),
}
}
#[test]
fn parse_ordered_list() {
let blocks = parse("1. first\n2. second\n");
assert!(matches!(&blocks[0], Block::List { ordered: true, items, .. } if items.len() == 2));
}
#[test]
fn parse_blockquote() {
let blocks = parse("> quoted\n");
assert!(matches!(&blocks[0], Block::BlockQuote { .. }));
}
#[test]
fn table_picks_up_trailing_tui_columns_comment() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n<!-- tui-columns: [10, 20] -->\n";
let blocks = parse(src);
assert_eq!(blocks.len(), 1, "expected one block, got: {blocks:?}");
match &blocks[0] {
Block::Table {
user_widths: Some(w),
..
} => assert_eq!(w, &vec![Some(10), Some(20)]),
other => panic!("expected Table with user_widths, got: {other:?}"),
}
}
#[test]
fn content_after_tui_columns_comment_is_preserved() {
let src =
"| a | b |\n|---|---|\n| 1 | 2 |\n<!-- tui-columns: [10, 20] -->\n\nContent below\n";
let blocks = parse(src);
assert_eq!(
blocks.len(),
2,
"expected Table + Paragraph, got: {blocks:?}"
);
assert!(matches!(
&blocks[0],
Block::Table {
user_widths: Some(_),
..
}
));
assert!(matches!(&blocks[1], Block::Paragraph { .. }));
}
#[test]
fn table_without_tui_columns_has_none_widths() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let blocks = parse(src);
match &blocks[0] {
Block::Table { user_widths, .. } => assert!(user_widths.is_none()),
other => panic!("expected Table, got: {other:?}"),
}
}
#[test]
fn parse_list_item_text_present() {
let blocks = parse("- item one\n- item two\n");
if let Block::List { items, .. } = &blocks[0] {
assert!(!items.is_empty(), "list has no items");
assert!(!items[0].blocks.is_empty(), "first item has no blocks");
if let Block::Paragraph { inlines } = &items[0].blocks[0] {
let text = super::super::ast::inlines_to_plain(inlines);
assert!(text.contains("item one"), "text was: {text:?}");
} else {
panic!("First block is not a Paragraph: {:?}", items[0].blocks[0]);
}
} else {
panic!("Expected List");
}
}
#[test]
fn image_only_paragraph_promotes_to_image_block() {
let blocks = parse("\n");
assert_eq!(blocks.len(), 1);
match &blocks[0] {
Block::ImageBlock { alt, url } => {
assert_eq!(alt, "cat");
assert_eq!(url, "cat.png");
}
other => panic!("expected ImageBlock, got {other:?}"),
}
}
#[test]
fn image_with_surrounding_whitespace_still_promotes() {
let blocks = parse("  \n");
assert!(
matches!(&blocks[0], Block::ImageBlock { url, .. } if url == "dog.png"),
"got {:?}",
blocks[0]
);
}
#[test]
fn mixed_content_paragraph_keeps_inline_image() {
let blocks = parse("Prefix  suffix\n");
match &blocks[0] {
Block::Paragraph { inlines } => {
assert!(inlines.iter().any(|i| matches!(i, Inline::Image { .. })));
}
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn multiple_stacked_image_paragraphs_each_promote() {
let blocks = parse("\n\n\n");
let image_blocks: Vec<_> = blocks
.iter()
.filter(|b| matches!(b, Block::ImageBlock { .. }))
.collect();
assert_eq!(image_blocks.len(), 2);
}
#[test]
fn paragraph_with_two_images_does_not_promote() {
let blocks = parse(" \n");
assert!(matches!(&blocks[0], Block::Paragraph { .. }));
}
#[test]
fn block_level_html_comment_promotes_to_html_comment() {
let blocks = parse("<!-- hello -->\n");
assert_eq!(blocks.len(), 1, "got {blocks:?}");
assert!(
matches!(&blocks[0], Block::HtmlComment(body) if body.trim() == "<!-- hello -->"),
"got {:?}",
blocks[0]
);
}
#[test]
fn block_level_html_tag_is_not_promoted() {
let blocks = parse("<div>stuff</div>\n");
assert!(matches!(&blocks[0], Block::Html(_)), "got {:?}", blocks[0]);
}
#[test]
fn isolated_tui_columns_comment_not_adjacent_to_table_stays_hidden_comment() {
let blocks = parse("<!-- tui-columns: [10, 20, 30] -->\n\nSome text.\n");
assert!(
matches!(&blocks[0], Block::HtmlComment(_)),
"got {:?}",
blocks[0]
);
assert!(matches!(&blocks[1], Block::Paragraph { .. }));
}
#[test]
fn inline_html_comment_produces_inline_html_comment_variant() {
let blocks = parse("hello <!-- aside --> world\n");
match &blocks[0] {
Block::Paragraph { inlines } => {
assert!(
inlines.iter().any(|i| matches!(i, Inline::HtmlComment(_))),
"inlines: {inlines:?}"
);
}
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn inline_html_tag_stays_as_text() {
let blocks = parse("line <br> end\n");
match &blocks[0] {
Block::Paragraph { inlines } => {
assert!(
inlines
.iter()
.any(|i| matches!(i, Inline::Text(t) if t.contains("<br>"))),
"inlines: {inlines:?}"
);
assert!(!inlines.iter().any(|i| matches!(i, Inline::HtmlComment(_))));
}
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn tui_columns_still_absorbed_after_html_comment_promotion() {
let src = "| a | b |\n|---|---|\n| 1 | 2 |\n<!-- tui-columns: [10, 20] -->\n";
let blocks = parse(src);
assert_eq!(blocks.len(), 1);
assert!(matches!(
&blocks[0],
Block::Table {
user_widths: Some(_),
..
}
));
}
fn only_list(blocks: &[Block]) -> (&[ListItem], bool, Option<u64>) {
let lists: Vec<&Block> = blocks
.iter()
.filter(|b| matches!(b, Block::List { .. }))
.collect();
assert_eq!(lists.len(), 1, "expected exactly one list, got {blocks:?}");
match lists[0] {
Block::List {
items,
ordered,
start,
} => (items, *ordered, *start),
_ => unreachable!(),
}
}
fn blanks_before(items: &[ListItem]) -> Vec<usize> {
items.iter().map(|it| it.blank_lines_before).collect()
}
#[test]
fn ordered_list_blank_between_items_stays_one_list_and_is_annotated() {
let blocks = parse("1. a\n2. b\n\n3. c\n4. d\n");
let (items, ordered, start) = only_list(&blocks);
assert!(ordered);
assert_eq!(start, Some(1));
assert_eq!(items.len(), 4);
assert_eq!(blanks_before(items), vec![0, 0, 1, 0]);
}
#[test]
fn ordered_list_restart_numbering_no_longer_splits() {
let blocks = parse("1. a\n2. b\n\n1. c\n2. d\n");
let (items, ordered, start) = only_list(&blocks);
assert!(ordered);
assert_eq!(start, Some(1));
assert_eq!(items.len(), 4);
assert_eq!(blanks_before(items), vec![0, 0, 1, 0]);
}
#[test]
fn bullet_list_blank_between_items_stays_one_list_and_is_annotated() {
let blocks = parse("- a\n- b\n\n- c\n- d\n");
let (items, ordered, _) = only_list(&blocks);
assert!(!ordered);
assert_eq!(items.len(), 4);
assert_eq!(blanks_before(items), vec![0, 0, 1, 0]);
}
#[test]
fn list_item_with_fenced_code_block_containing_blank_line_is_not_annotated() {
let src = "- intro\n ```toml\n [a]\n\n [b]\n ```\n trailing\n- next item\n";
let blocks = parse(src);
let (items, _, _) = only_list(&blocks);
assert_eq!(items.len(), 2);
assert_eq!(blanks_before(items), vec![0, 0]);
}
#[test]
fn ordered_list_no_blank_line_annotates_all_zero() {
let blocks = parse("1. a\n2. b\n3. c\n");
let (items, ordered, _) = only_list(&blocks);
assert!(ordered);
assert_eq!(items.len(), 3);
assert_eq!(blanks_before(items), vec![0, 0, 0]);
}
#[test]
fn nested_list_with_blank_line_inside_top_level_item_stays_one_list() {
let blocks = parse("- outer\n - nested\n- next\n");
let (items, _, _) = only_list(&blocks);
assert_eq!(items.len(), 2);
assert_eq!(blanks_before(items), vec![0, 0]);
}
#[test]
fn every_gap_blank_separated_ordered_list_is_annotated_per_item() {
let blocks = parse("1. a\n\n1. b\n\n1. c\n");
let (items, ordered, start) = only_list(&blocks);
assert!(ordered);
assert_eq!(start, Some(1));
assert_eq!(items.len(), 3);
assert_eq!(blanks_before(items), vec![0, 1, 1]);
}
#[test]
fn interior_blank_without_separator_blank_is_not_annotated() {
let blocks = parse("- a\n\n cont\n- b\n");
let (items, _, _) = only_list(&blocks);
assert_eq!(items.len(), 2);
assert_eq!(blanks_before(items), vec![0, 0]);
}
#[test]
fn double_blank_between_items_counts_two() {
let blocks = parse("- a\n\n\n- b\n");
let (items, _, _) = only_list(&blocks);
assert_eq!(items.len(), 2);
assert_eq!(blanks_before(items), vec![0, 2]);
}
#[test]
fn multi_line_item_content_before_separator_blank_counts_one() {
let src = "1. **first** item\n continuation\n\n ```rust\n let x = 1;\n ```\n\n2. second\n";
let (mut blocks, ranges) = parse_raw_with_ranges(src);
annotate_list_blanks(&mut blocks, &ranges, src);
let (items, _, _) = only_list(&blocks);
assert_eq!(items.len(), 2);
assert_eq!(blanks_before(items), vec![0, 1]);
}
#[test]
fn footnote_reference_and_definition_parse() {
let blocks = parse("Text.[^1]\n\n[^1]: The note.\n");
match &blocks[0] {
Block::Paragraph { inlines } => {
assert!(
inlines.iter().any(
|i| matches!(i, Inline::FootnoteReference { label, .. } if label == "1")
),
"inlines: {inlines:?}"
);
}
other => panic!("expected Paragraph, got {other:?}"),
}
assert!(
blocks
.iter()
.any(|b| matches!(b, Block::FootnoteDefinition { label, .. } if label == "1")),
"blocks: {blocks:?}"
);
}
#[test]
fn footnote_labels_are_preserved_verbatim() {
let src = "First[^3] then[^1].\n\n[^1]: one.\n\n[^3]: three.\n";
let blocks = parse(src);
let mut ref_labels: Vec<String> = Vec::new();
for b in &blocks {
if let Block::Paragraph { inlines } = b {
for i in inlines {
if let Inline::FootnoteReference { label } = i {
ref_labels.push(label.clone());
}
}
}
}
assert_eq!(ref_labels, vec!["3".to_string(), "1".to_string()]);
let def_labels: Vec<String> = blocks
.iter()
.filter_map(|b| match b {
Block::FootnoteDefinition { label, .. } => Some(label.clone()),
_ => None,
})
.collect();
assert_eq!(def_labels, vec!["1".to_string(), "3".to_string()]);
}
#[test]
fn undefined_footnote_reference_stays_literal_text() {
let blocks = parse("A dangling[^x] marker.\n");
match &blocks[0] {
Block::Paragraph { inlines } => {
assert!(
!inlines
.iter()
.any(|i| matches!(i, Inline::FootnoteReference { .. })),
"undefined ref should not parse as a footnote: {inlines:?}"
);
}
other => panic!("expected Paragraph, got {other:?}"),
}
}
#[test]
fn is_html_comment_only_detects_comment_and_rejects_other_html() {
assert!(post_pass::is_html_comment_only("<!-- hi -->"));
assert!(post_pass::is_html_comment_only(" <!-- hi --> "));
assert!(post_pass::is_html_comment_only("<!---->"));
assert!(post_pass::is_html_comment_only("<!-- a --> <!-- b -->"));
assert!(post_pass::is_html_comment_only("<!-- a --><!-- b -->"));
assert!(!post_pass::is_html_comment_only("<div>foo</div>"));
assert!(!post_pass::is_html_comment_only("<!-- a --> tail"));
assert!(!post_pass::is_html_comment_only("<!-- a"));
assert!(!post_pass::is_html_comment_only("<!-->"));
}
}