use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
use std::fs;
use std::process::Command;
use tempfile::tempdir;
type Block = (&'static str, String, Vec<&'static str>);
pub(super) fn structure(content: &str) -> Vec<Block> {
let mut path: Vec<&'static str> = Vec::new();
let mut blocks: Vec<Block> = Vec::new();
let mut current: Option<Block> = None;
let mut implicit = false;
for event in Parser::new_ext(content, Options::empty()) {
let structural = matches!(
event,
Event::Start(
Tag::List(_)
| Tag::Item
| Tag::BlockQuote(_)
| Tag::Paragraph
| Tag::Heading { .. }
| Tag::CodeBlock(_)
) | Event::End(TagEnd::List(_) | TagEnd::Item | TagEnd::BlockQuote(_))
| Event::Rule
);
if structural && implicit {
blocks.extend(current.take());
implicit = false;
}
match event {
Event::Start(Tag::List(_)) => path.push("list"),
Event::Start(Tag::Item) => path.push("item"),
Event::Start(Tag::BlockQuote(_)) => path.push("quote"),
Event::End(TagEnd::List(_) | TagEnd::Item | TagEnd::BlockQuote(_)) => {
path.pop();
}
Event::Start(Tag::Paragraph) => current = Some(("p", String::new(), path.clone())),
Event::Start(Tag::Heading { .. }) => current = Some(("h", String::new(), path.clone())),
Event::Start(Tag::CodeBlock(_)) => current = Some(("code", String::new(), path.clone())),
Event::Rule => blocks.push(("hr", String::new(), path.clone())),
Event::Text(text) | Event::Code(text) => {
if current.is_none() {
current = Some(("p", String::new(), path.clone()));
implicit = true;
}
if let Some(block) = current.as_mut() {
block.1.push_str(&text);
}
}
Event::SoftBreak => {
if let Some(block) = current.as_mut() {
block.1.push(' ');
}
}
Event::End(TagEnd::Paragraph | TagEnd::Heading(_) | TagEnd::CodeBlock) => blocks.extend(current.take()),
_ => {}
}
}
blocks.extend(current.take());
blocks
}
fn fix(content: &str, extra: &[&str]) -> String {
let dir = tempdir().unwrap();
let path = dir.path().join("doc.md");
fs::write(&path, content).unwrap();
Command::new(env!("CARGO_BIN_EXE_rumdl"))
.args(["check", "--isolated", "--no-cache", "--fix"])
.args(extra)
.arg(&path)
.output()
.expect("run rumdl check --fix");
fs::read_to_string(&path).unwrap()
}
const MD077: &[&str] = &["--enable", "MD077"];
const CORPUS: &[&str] = &[
"-\n a\n",
"+\n a\n",
"*\n a\n b\n",
"1.\n a\n",
"10.\n a\n",
"1)\n a\n",
"- x\n\n -\n a\n",
"-\n a\n",
"1.\n a\n",
"-\n a\n\n b\n",
"-\n foo\n-\n ```\n bar\n ```\n-\n baz\n",
"-\n ```\n x\n ```\n\n after\n",
"1.\n ```\n x\n ```\n\n after\n",
"-\n\n a\n",
"-\n\n a\n",
"1.\n\n a\n",
"-\n a\n",
"-\tx\n\n\ty\n",
"-\tx\n\n\t y\n",
"1.\tx\n\n\ty\n",
"-\tx\n\n y\n",
"- t\n\n y\n",
"- t\n\n y\n",
"1. t\n\n y\n",
"- [ ] task\n\n y\n",
"- 1. x\n\n y\n",
"1. -\n f\n",
"- 1. ```\n ```\n b\n",
"- - a\n ---\n",
"- - a\n\n b\n",
"- 1. x\n\n y\n",
"- 1. x\n\n y\n",
"- - [ ] task\n wrap\n",
"- - [ ] task\n wrap\n",
"- -\n\n a\n",
"- -\n a\n\n b\n",
"- \u{0661}\u{0662}\u{0663}\u{0664}. x\n\n y\n",
"- \u{a0}\n a\n\n y\n",
"- -\n \u{a0}\n\n b\n",
"- a\n\n b\n",
"1. a\n\n b\n",
"- a\n b\n",
"- [ ] t\n\n w\n",
"- a\n - b\n\n c\n",
"1. a\n - b\n\n c\n",
];
#[test]
fn md077_alone_keeps_every_block_where_it_is() {
for content in CORPUS {
let before = structure(content);
let after = fix(content, MD077);
assert_eq!(before, structure(&after), "{content:?} became {after:?}");
}
}
#[test]
fn fixing_twice_changes_nothing_more() {
for content in CORPUS {
for extra in [&[][..], MD077] {
let once = fix(content, extra);
let twice = fix(&once, extra);
assert_eq!(once, twice, "{content:?} with {extra:?}");
}
}
}
#[test]
fn md077_writes_these_exact_results() {
for (content, expected) in [
("- e\n\n t\n", "- e\n\n t\n"),
("1. a\n\n b\n", "1. a\n\n b\n"),
("- a\n\n b\n", "- a\n\n b\n"),
("-\n a\n\n b\n", "-\n a\n\n b\n"),
("-\n a\n", "-\n a\n"),
("1.\n a\n", "1.\n a\n"),
("- t\n\n y\n", "- t\n\n y\n"),
("- [ ] task\n\n y\n", "- [ ] task\n\n y\n"),
("-\tx\n\n y\n", "-\tx\n\n y\n"),
("-\tx\n\n\t y\n", "-\tx\n\n y\n"),
("- 1. x\n\n y\n", "- 1. x\n\n y\n"),
("- -\n\n a\n", "- -\n\n a\n"),
] {
assert_eq!(fix(content, MD077), expected, "{content:?}");
}
}
#[test]
fn the_oracle_sees_content_that_leaves_its_item() {
let inside = structure("-\n a\n");
let outside = structure("-\n a\n");
assert_ne!(inside, outside);
assert_eq!(inside[0].2, ["list", "item"]);
assert!(outside[0].2.is_empty(), "{outside:?}");
let nested = structure("- 1. x\n\n y\n");
let dedented = structure("- 1. x\n\n y\n");
assert_ne!(nested, dedented);
let merged = structure("- t\n\n y\n");
assert_eq!(merged.iter().filter(|block| block.0 == "code").count(), 1);
assert_eq!(
structure("- t\n\n y\n")
.iter()
.filter(|block| block.0 == "code")
.count(),
1
);
}
#[test]
fn the_oracle_ignores_indentation_that_changes_nothing() {
assert_eq!(structure("-\n a\n"), structure("-\n a\n"));
assert_eq!(structure("- a\n\n b\n"), structure("- a\n\n b\n"));
}
#[test]
fn md077_changes_a_document_that_needs_it() {
assert_ne!(fix("- e\n\n t\n", MD077), "- e\n\n t\n");
}