pub use pulldown_cmark::{CodeBlockKind, LinkType, Options, Parser};
pub fn default_options() -> Options {
Options::ENABLE_TABLES
| Options::ENABLE_FOOTNOTES
| Options::ENABLE_STRIKETHROUGH
| Options::ENABLE_TASKLISTS
| Options::ENABLE_GFM
}
pub fn parse(source: &str) -> Parser<'_> {
Parser::new_ext(source, default_options())
}
pub fn parse_with_options(source: &str, options: Options) -> Parser<'_> {
Parser::new_ext(source, options)
}
pub fn code_block_language<'a>(kind: &'a CodeBlockKind<'a>) -> Option<&'a str> {
match kind {
CodeBlockKind::Fenced(info) => {
let info = info.trim();
if info.is_empty() {
None
} else {
Some(info.split_whitespace().next().unwrap_or(info))
}
}
CodeBlockKind::Indented => None,
}
}
pub fn has_open_code_fence(source: &str) -> bool {
let mut open: Option<(u8, usize)> = None;
for line in source.lines() {
match (open, fence_at_line_start(line)) {
(None, Some(fence)) => open = Some(fence),
(Some((open_char, open_len)), Some((line_char, line_len)))
if line_char == open_char && line_len >= open_len =>
{
open = None;
}
_ => {}
}
}
open.is_some()
}
fn fence_at_line_start(line: &str) -> Option<(u8, usize)> {
let bytes = line.as_bytes();
let indent = bytes.iter().take_while(|byte| **byte == b' ').count();
if indent > 3 {
return None;
}
let fence_char = *bytes.get(indent)?;
if fence_char != b'`' && fence_char != b'~' {
return None;
}
let len = bytes[indent..]
.iter()
.take_while(|byte| **byte == fence_char)
.count();
(len >= 3).then_some((fence_char, len))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_basic() {
let source = "# Hello\n\nWorld";
let events: Vec<_> = parse(source).collect();
assert!(!events.is_empty());
}
#[test]
fn test_code_block_language() {
use pulldown_cmark::CowStr;
let rust = CodeBlockKind::Fenced(CowStr::from("rust"));
assert_eq!(code_block_language(&rust), Some("rust"));
let rust_with_attrs = CodeBlockKind::Fenced(CowStr::from("rust,linenos"));
assert_eq!(code_block_language(&rust_with_attrs), Some("rust,linenos"));
let empty = CodeBlockKind::Fenced(CowStr::from(""));
assert_eq!(code_block_language(&empty), None);
let indented = CodeBlockKind::Indented;
assert_eq!(code_block_language(&indented), None);
}
#[test]
fn default_options_are_exactly_the_gfm_five() {
let options = default_options();
for on in [
Options::ENABLE_TABLES,
Options::ENABLE_FOOTNOTES,
Options::ENABLE_STRIKETHROUGH,
Options::ENABLE_TASKLISTS,
Options::ENABLE_GFM,
] {
assert!(options.contains(on), "expected {on:?} to be enabled");
}
for off in [
Options::ENABLE_SMART_PUNCTUATION,
Options::ENABLE_HEADING_ATTRIBUTES,
Options::ENABLE_YAML_STYLE_METADATA_BLOCKS,
Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS,
Options::ENABLE_OLD_FOOTNOTES,
Options::ENABLE_MATH,
Options::ENABLE_DEFINITION_LIST,
Options::ENABLE_SUPERSCRIPT,
Options::ENABLE_SUBSCRIPT,
Options::ENABLE_WIKILINKS,
] {
assert!(!options.contains(off), "expected {off:?} to be disabled");
}
}
#[test]
fn single_tilde_is_strikethrough_not_subscript() {
use pulldown_cmark::{Event, Tag};
let events: Vec<_> = parse("~struck~").collect();
assert!(
events
.iter()
.any(|e| matches!(e, Event::Start(Tag::Strikethrough))),
"single tildes should still be strikethrough: {events:?}"
);
assert!(
!events
.iter()
.any(|e| matches!(e, Event::Start(Tag::Subscript))),
"subscript must stay off: {events:?}"
);
}
#[test]
fn wikilinks_stay_literal_text() {
use pulldown_cmark::{Event, Tag};
let events: Vec<_> = parse("[[foo|bar]]").collect();
assert!(
!events
.iter()
.any(|e| matches!(e, Event::Start(Tag::Link { .. }))),
"wikilinks must stay off: {events:?}"
);
}
#[test]
fn gfm_alerts_reach_block_quote_kind() {
use pulldown_cmark::{BlockQuoteKind, Event, Tag};
let events: Vec<_> = parse("> [!NOTE]\n> Something worth knowing.").collect();
assert!(
events
.iter()
.any(|e| matches!(e, Event::Start(Tag::BlockQuote(Some(BlockQuoteKind::Note))))),
"expected a Note alert: {events:?}"
);
}
#[test]
fn task_list_markers_are_emitted() {
use pulldown_cmark::Event;
let events: Vec<_> = parse("- [x] done\n- [ ] todo").collect();
let markers: Vec<bool> = events
.iter()
.filter_map(|e| match e {
Event::TaskListMarker(checked) => Some(*checked),
_ => None,
})
.collect();
assert_eq!(markers, vec![true, false]);
}
#[test]
fn table_column_alignments_survive() {
use pulldown_cmark::{Alignment, Event, Tag};
let source = "| a | b | c |\n|:--|:-:|--:|\n| 1 | 2 | 3 |";
let alignments = parse(source).find_map(|e| match e {
Event::Start(Tag::Table(alignments)) => Some(alignments),
_ => None,
});
assert_eq!(
alignments,
Some(vec![Alignment::Left, Alignment::Center, Alignment::Right])
);
}
#[test]
fn offset_ranges_land_on_char_boundaries() {
let source = "# Überschrift\n\nEin Absatz mit **fettem** Text — und einem Emoji 🎉.\n\n\
- Ein Listenpunkt mit `Code`\n";
for (event, range) in parse(source).into_offset_iter() {
assert!(
source.is_char_boundary(range.start) && source.is_char_boundary(range.end),
"range {range:?} splits a codepoint for {event:?}"
);
assert!(range.end <= source.len(), "range {range:?} out of bounds");
}
}
#[test]
fn a_streamed_fence_reads_as_open_until_its_closer_arrives() {
let full = "Here you go:\n\n```rust\nfn main() {\n println!(\"hi\");\n}\n```\n\nDone.";
let opener_at = full.find("```rust").expect("the opening fence");
let closer_at = full.find("\n```\n\nDone").expect("the closing fence") + 1;
for end in 0..full.len() {
if !full.is_char_boundary(end) {
continue;
}
let prefix = &full[..end];
let opened = end >= opener_at + 3;
let closed = end >= closer_at + 3;
assert_eq!(
has_open_code_fence(prefix),
opened && !closed,
"prefix of {end} bytes: {prefix:?}"
);
}
}
#[test]
fn a_closer_must_match_the_opener() {
assert!(has_open_code_fence("```rust\nx\n~~~\n"));
assert!(has_open_code_fence("~~~rust\nx\n```\n"));
assert!(has_open_code_fence("````\nx\n```\n"));
assert!(!has_open_code_fence("```\nx\n`````\n"));
}
#[test]
fn indentation_decides_whether_a_run_is_a_fence_at_all() {
assert!(has_open_code_fence(" ```rust\nx\n"));
assert!(!has_open_code_fence("```rust\nx\n ```\n"));
assert!(!has_open_code_fence(" ```rust\nx\n"));
assert!(has_open_code_fence("```rust\nx\n ```\n"));
}
#[test]
fn a_run_that_is_not_at_a_line_start_is_text() {
assert!(!has_open_code_fence("see ```rust for the fence\n"));
assert!(has_open_code_fence("```\nsee ``` inline\n"));
}
#[test]
fn only_the_last_fence_can_be_open() {
assert!(!has_open_code_fence("```\na\n```\n\ntext\n\n```\nb\n```\n"));
assert!(has_open_code_fence("```\na\n```\n\ntext\n\n```\nb\n"));
assert!(!has_open_code_fence("no code at all\n"));
assert!(!has_open_code_fence(""));
}
#[test]
fn the_scan_only_ever_errs_towards_closed() {
use pulldown_cmark::{Event, Tag, TagEnd};
let nested = "```markdown\n```rust\n";
let events: Vec<_> = parse(nested).collect();
let starts = events
.iter()
.filter(|e| matches!(e, Event::Start(Tag::CodeBlock(_))))
.count();
assert_eq!(starts, 1, "pulldown-cmark should see one block: {events:?}");
assert!(
!has_open_code_fence(nested),
"the scan is expected to disagree here, in the harmless direction"
);
let sources = [
"```rust\nfn main() {",
"```\n",
"~~~\nplain\n",
"text\n\n```py\nx = 1\n",
"```rust\nfn main() {}\n```\n",
"```rust\nfn main() {}\n```\n\ntrailing prose",
" indented\n",
"# heading\n\nno code\n",
"> ```rust\n> quoted\n",
"- item\n\n ```rust\n fn f() {}\n ```\n",
];
for source in sources {
if !has_open_code_fence(source) {
continue;
}
let events: Vec<_> = parse(source).collect();
assert!(
events
.iter()
.any(|e| matches!(e, Event::Start(Tag::CodeBlock(_)))),
"the scan says {source:?} has an open fence, but there is no code block"
);
assert!(
matches!(events.last(), Some(Event::End(TagEnd::CodeBlock))),
"the scan says {source:?} has an open fence, but pulldown-cmark put content \
after the last code block — that would strip a settled block's colors: \
{events:?}"
);
}
}
}