use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
use serde_json::Value;
use std::fs;
use std::process::Command;
use tempfile::tempdir;
fn top_level_heading_lines(content: &str) -> Vec<usize> {
let mut lines = Vec::new();
let mut depth = 0usize;
for (event, range) in Parser::new_ext(content, Options::ENABLE_YAML_STYLE_METADATA_BLOCKS).into_offset_iter() {
match event {
Event::Start(Tag::Item | Tag::BlockQuote(_)) => depth += 1,
Event::End(TagEnd::Item | TagEnd::BlockQuote(_)) => depth -= 1,
Event::Start(Tag::Heading { .. }) if depth == 0 => {
lines.push(content[..range.start].matches('\n').count() + 1);
}
_ => {}
}
}
lines
}
fn md052_lines(content: &str) -> Vec<u64> {
let dir = tempdir().unwrap();
let path = dir.path().join("doc.md");
fs::write(&path, content).unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_rumdl"))
.args(["check", "--no-cache", "--isolated", "--output-format", "json"])
.arg(&path)
.output()
.expect("run rumdl check");
let warnings: Value = serde_json::from_slice(&output.stdout).unwrap_or_else(|e| {
panic!(
"unparseable output ({e}): {}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
});
warnings
.as_array()
.unwrap()
.iter()
.filter(|w| w["rule"] == "MD052")
.map(|w| w["line"].as_u64().unwrap())
.collect()
}
struct Row {
name: &'static str,
content: &'static str,
headings: &'static [usize],
reported: &'static [u64],
}
const ROWS: &[Row] = &[
Row {
name: "no section",
content: "# T\n\nSee [foo][bar].\n",
headings: &[1],
reported: &[3],
},
Row {
name: "plain section",
content: "# T\n\n## Example\n\nSee [foo][bar].\n",
headings: &[1, 3],
reported: &[],
},
Row {
name: "shell comment in a fence keeps the section",
content: "# T\n\n## Example\n\n```sh\n# a comment\n```\n\nSee [foo][bar].\n",
headings: &[1, 3],
reported: &[],
},
Row {
name: "echo in a fence keeps the section",
content: "# T\n\n## Example\n\n```sh\necho\n```\n\nSee [foo][bar].\n",
headings: &[1, 3],
reported: &[],
},
Row {
name: "Output heading in a fence starts nothing",
content: "# T\n\n```\n## Output\n```\n\nSee [foo][bar].\n",
headings: &[1],
reported: &[7],
},
Row {
name: "Output heading in an HTML comment starts nothing",
content: "# T\n\n<!--\n## Output\n-->\n\nSee [foo][bar].\n",
headings: &[1],
reported: &[7],
},
Row {
name: "Output heading in front matter starts nothing",
content: "---\ntitle: x\n## Output\n---\n\n# T\n\nSee [foo][bar].\n",
headings: &[6],
reported: &[8],
},
Row {
name: "hashtag paragraph does not end the section",
content: "## Example\n\n#hashtag\n\nSee [foo][bar].\n",
headings: &[1],
reported: &[],
},
Row {
name: "heading-like line in an HTML block does not end the section",
content: "## Example\n\n<div>\n# not a heading\n</div>\n\nSee [foo][bar].\n",
headings: &[1],
reported: &[],
},
Row {
name: "setext Example starts a section",
content: "Example\n-------\n\nSee [foo][bar].\n",
headings: &[1],
reported: &[],
},
Row {
name: "setext heading ends the section",
content: "## Example\n\nSee [a][b].\n\nOther\n-----\n\nSee [foo][bar].\n",
headings: &[1, 5],
reported: &[8],
},
Row {
name: "closing hashes do not hide the title",
content: "## Example ##\n\nSee [foo][bar].\n",
headings: &[1],
reported: &[],
},
Row {
name: "a deeper heading ends the section",
content: "## Example\n\n### Details\n\nSee [foo][bar].\n",
headings: &[1, 3],
reported: &[5],
},
Row {
name: "heading in a list item does not start a section",
content: "- ## Example\n\n text\n\nSee [foo][bar].\n",
headings: &[],
reported: &[5],
},
Row {
name: "heading in a list item does not end the section",
content: "## Example\n\n- # x\n\nSee [foo][bar].\n",
headings: &[1],
reported: &[],
},
Row {
name: "heading in a blockquote does not start a section",
content: "> ## Example\n\nSee [foo][bar].\n",
headings: &[],
reported: &[3],
},
Row {
name: "heading in a blockquote does not end the section",
content: "## Example\n\n> # x\n\nSee [foo][bar].\n",
headings: &[1],
reported: &[],
},
];
#[test]
fn example_sections_follow_parsed_headings() {
for row in ROWS {
assert_eq!(
top_level_heading_lines(row.content),
row.headings,
"oracle disagrees with the row's heading lines: {}",
row.name
);
assert_eq!(
md052_lines(row.content),
row.reported,
"{}: {:?}",
row.name,
row.content
);
}
}
#[test]
fn check_fix_leaves_example_section_documents_unchanged() {
let content = "# T\n\n```text\n## Output\n```\n\nSee [foo][bar].\n";
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", "--no-cache", "--isolated", "--fix"])
.arg(&path)
.output()
.unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), content);
assert_eq!(md052_lines(content), [7], "second run reports the same line");
}