use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
use std::fs;
use std::process::Command;
use tempfile::tempdir;
fn structure(content: &str, compare_code_text: bool) -> Vec<String> {
let mut tokens = Vec::new();
let mut code_lines: Option<usize> = None;
let mut code_text = String::new();
for (event, range) in Parser::new_ext(content, Options::empty()).into_offset_iter() {
match event {
Event::Start(Tag::List(first)) => tokens.push(format!("list:{first:?}")),
Event::Start(Tag::Item) => tokens.push("item".into()),
Event::Start(Tag::BlockQuote(_)) => tokens.push("quote".into()),
Event::Start(Tag::Paragraph) => tokens.push("para".into()),
Event::Start(Tag::Heading { level, .. }) => tokens.push(format!("heading:{}", level as u8)),
Event::Start(Tag::CodeBlock(kind)) => {
let fenced = matches!(kind, CodeBlockKind::Fenced(_));
tokens.push(format!("code:fenced={fenced}"));
code_lines = Some(0);
code_text.clear();
}
Event::End(
TagEnd::List(_) | TagEnd::Item | TagEnd::BlockQuote(_) | TagEnd::Paragraph | TagEnd::Heading(_),
) => tokens.push("end".into()),
Event::End(TagEnd::CodeBlock) => {
let lines = code_lines.take().unwrap_or(0);
tokens.push(if compare_code_text {
format!("code-end:{code_text:?}")
} else {
format!("code-end:lines={lines}")
});
}
Event::Text(text) => {
if let Some(lines) = code_lines.as_mut() {
*lines += text.matches('\n').count();
code_text.push_str(&text);
}
}
Event::SoftBreak => tokens.push("soft".into()),
Event::HardBreak => {
let written = &content[range];
let before_newline = written.trim_end_matches(['\r', '\n']);
let hard = before_newline.ends_with('\\') || before_newline.ends_with(" ");
tokens.push(if hard { "hard" } else { "soft" }.into());
}
_ => {}
}
}
tokens
}
fn run_fix(content: &str, extra: &[&str], config: Option<&str>) -> String {
let dir = tempdir().unwrap();
let path = dir.path().join("doc.md");
fs::write(&path, content).unwrap();
let mut command = Command::new(env!("CARGO_BIN_EXE_rumdl"));
command.args(["check", "--no-cache", "--fix"]);
match config {
Some(config) => {
let config_path = dir.path().join("rumdl.toml");
fs::write(&config_path, config).unwrap();
command.arg("--config").arg(config_path);
}
None => {
command.arg("--isolated");
}
}
command.args(extra).arg(&path).output().expect("run rumdl check --fix");
fs::read_to_string(&path).unwrap()
}
struct Row {
source: &'static str,
code_text_converted: bool,
}
const fn row(source: &'static str) -> Row {
Row {
source,
code_text_converted: false,
}
}
fn corpus() -> Vec<Row> {
let mut rows: Vec<Row> = [
"-\tx\n\n ## a\n",
"*\tx\n\n ## a\n",
"+\tx\n\n ## a\n",
"- \tx\n\n ## a\n",
"1.\tx\n\n ## a\n",
"1)\tx\n\n ## a\n",
"10.\tx\n\n ## a\n",
"100.\tx\n\n ## a\n",
"-\t\tx\n\n ## a\n",
"-\t-\tx\n\n ## a\n",
"1.\t-\tx\n\n ## a\n",
"-\tx\n\n\ty\n\n\t## a\n",
"- x\n\n\tcont\n\n\t## a\n",
"- x\n\t- y\n\n\t ## a\n",
"- x\n\t- y\n\t\t- z\n",
"1. x\n\t1. y\n\t\t1. z\n",
"10. x\n\t1. y\n\n\t ## a\n",
"- x\n\n \tcont\n\n \t## a\n",
"- x\n\n \tcont\n",
"- x\n\n \tcont\n",
"- x\n\n\t cont\n",
"- x\n - y\n\n \t## a\n",
">\tfoo\n",
">\t\tfoo\n",
"> \tfoo\n",
">\t- a\n>\t b\n",
">\t> foo\n",
"-\t> foo\n\n ## a\n",
"- > a\n >\tb\n",
"> - x\n>\n>\t## a\n",
"> -\tx\n>\n> ## a\n",
"> \t- x\n>\n> ## a\n",
"para\n\n\tcode\n\t\tmore\n",
"para\n\n \tcode\n",
"para\n\n \tcode\n",
"- x\n\n\t\tcode\n",
"1. x\n\n\t\t\tcode\n",
"para\n\n\t# not heading\n",
"foo\n\t---\n",
"```\n\tcode\n```\n",
"```\n\t```\nfoo\n",
"- ```\n\tcode\n ```\n",
"> ```\n>\tcode\n> ```\n",
"1. x\n\n ```\n\tcode\n ```\n",
"```\n\t\n```\n",
"foo\t\nbar\n",
"foo \t\nbar\n",
"foo \t\nbar\n",
"foo\t\t\nbar\n",
"foo\t \nbar\n",
"foo\t \nbar\n",
"foo\\\t\nbar\n",
"foo\n+\t\nbar\n",
"foo\n1.\t\nbar\n",
"- foo\t\n bar\n",
"> foo\t\n> bar\n",
"-\t\n x\n",
"1.\t\n x\n",
">\t\n> x\n",
"# h\t\n\ntext\n",
"a\tb\n",
"- a\tb\n",
"|a\t|b|\n|-|-|\n|1\t|2|\n",
"#\tfoo\n",
"-\t-\t-\n",
"[^1]:\tfoo\n\n bar\n",
"foo\n\tbar\n",
"foo\n2.\tbar\n",
]
.into_iter()
.map(row)
.collect();
rows.push(Row {
source: "para\n\n a\tb\n",
code_text_converted: true,
});
rows.push(Row {
source: "```\nfoo\tbar\nbaz\t\n```\n",
code_text_converted: true,
});
rows
}
const OTHER_RULE_ROWS: &[&str] = &[
"-\t-\tx\n\n ## a\n",
"foo\t \nbar\n",
"-\t\n x\n",
"1.\t\n x\n",
];
fn check_rows(label: &str, extra: &[&str], config: Option<&str>, compare_code_text: bool) {
let mut failures = Vec::new();
for Row {
source,
code_text_converted,
} in corpus()
{
if extra.is_empty() && OTHER_RULE_ROWS.contains(&source) {
continue;
}
let compare = compare_code_text && !code_text_converted;
let fixed = run_fix(source, extra, config);
if structure(source, compare) != structure(&fixed, compare) {
failures.push(format!("{source:?} -> {fixed:?}"));
}
let again = run_fix(&fixed, extra, config);
if again != fixed {
failures.push(format!("not idempotent: {source:?} -> {fixed:?} -> {again:?}"));
}
}
assert!(failures.is_empty(), "{label}:\n{}", failures.join("\n"));
}
fn config(spaces_per_tab: usize, code_blocks: bool) -> String {
format!("[MD010]\nspaces-per-tab = {spaces_per_tab}\ncode-blocks = {code_blocks}\n")
}
#[test]
fn md010_alone_keeps_the_structure_of_every_row() {
check_rows("--enable MD010", &["--enable", "MD010"], None, true);
}
#[test]
fn md010_keeps_the_structure_for_any_spaces_per_tab() {
for spaces_per_tab in [1, 2, 3, 8] {
check_rows(
&format!("spaces-per-tab = {spaces_per_tab}"),
&["--enable", "MD010"],
Some(&config(spaces_per_tab, false)),
true,
);
}
}
#[test]
fn md010_keeps_the_structure_when_it_also_fixes_code_blocks() {
for spaces_per_tab in [2, 4, 8] {
check_rows(
&format!("code-blocks = true, spaces-per-tab = {spaces_per_tab}"),
&["--enable", "MD010"],
Some(&config(spaces_per_tab, true)),
false,
);
}
}
#[test]
fn the_default_rules_keep_the_structure_of_every_row() {
check_rows("default rules", &[], None, true);
}
#[test]
fn md010_writes_these_exact_results() {
for (content, expected) in [
("-\tx\n\n ## a\n", "- x\n\n ## a\n"),
("1.\tx\n\n ## a\n", "1. x\n\n ## a\n"),
("10.\tx\n\n ## a\n", "10. x\n\n ## a\n"),
("- x\n\n \tcont\n", "- x\n\n cont\n"),
("> \tfoo\n", "> foo\n"),
("foo\t\nbar\n", "foo\nbar\n"),
("foo \t\nbar\n", "foo\nbar\n"),
("foo\\\t\nbar\n", "foo\\\t\nbar\n"),
("a\tb\n", "a b\n"),
("- a\tb\n", "- a b\n"),
] {
assert_eq!(run_fix(content, &["--enable", "MD010"], None), expected, "{content:?}");
}
assert_eq!(
run_fix("a\tb\n", &["--enable", "MD010"], Some(&config(2, false))),
"a b\n"
);
}
#[test]
fn the_oracle_detects_the_corruption_the_fixed_width_caused() {
for (source, broken) in [
("-\tx\n\n ## a\n", "- x\n\n ## a\n"),
("10.\tx\n\n ## a\n", "10. x\n\n ## a\n"),
("- x\n\n \tcont\n", "- x\n\n cont\n"),
("> \tfoo\n", "> foo\n"),
("foo\t\nbar\n", "foo \nbar\n"),
("foo\t \nbar\n", "foo \nbar\n"),
] {
assert_ne!(structure(source, true), structure(broken, true), "{source:?}");
}
}