use std::fmt::Write as _;
use std::ops::Range;
use std::path::PathBuf;
use pulldown_cmark::{Event, Parser, Tag};
use crate::config::Config;
use crate::preview::markdown::model::{
self, code_body_text, html_body_text, html_body_text_in, is_stray_inline_leaf,
is_unmodeled_container_tag, Block, BlockKind, Doc,
};
use super::md_snapshot_tests::{all_cases, assert_matches_snapshot, pre_src_for};
fn snapshot_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("snapshots")
.join("markdown_model.snap")
}
fn snippet(s: &str) -> String {
const CAP: usize = 80;
if s.chars().count() <= CAP {
format!("{s:?}")
} else {
let truncated: String = s.chars().take(CAP).collect();
format!("{truncated:?}…")
}
}
fn fmt_range(r: &Range<usize>) -> String {
format!("{}..{}", r.start, r.end)
}
fn inline_text(events: &[(Event<'_>, Range<usize>)], inline: &Range<usize>) -> String {
let mut s = String::new();
for (ev, _) in &events[inline.clone()] {
match ev {
Event::Text(t) => s.push_str(t),
Event::Code(c) => s.push_str(c),
Event::SoftBreak => s.push(' '),
Event::HardBreak => s.push('\n'),
_ => {}
}
}
s
}
fn fmt_kind(k: &BlockKind, src: &str, events: &[(Event<'_>, Range<usize>)]) -> String {
match k {
BlockKind::Heading {
level,
inline,
id,
classes,
attrs,
} => format!(
"Heading{{level={level}, inline={}:{}, id={id:?}, classes={classes:?}, attrs={attrs:?}}}",
fmt_range(inline),
snippet(&inline_text(events, inline)),
),
BlockKind::Paragraph { inline } => format!(
"Paragraph{{inline={}:{}}}",
fmt_range(inline),
snippet(&inline_text(events, inline)),
),
BlockKind::CodeBlock {
lang,
fenced,
body_spans,
} => {
let spans: Vec<String> = body_spans
.iter()
.map(|r| format!("{}:{}", fmt_range(r), snippet(&src[r.clone()])))
.collect();
format!(
"CodeBlock{{lang={lang:?}, fenced={fenced}, body_spans=[{}], text={}}}",
spans.join(", "),
snippet(&code_body_text(body_spans, src))
)
}
BlockKind::List { ordered, start } => {
format!("List{{ordered={ordered}, start={start:?}}}")
}
BlockKind::ListItem { task: None } => "ListItem{task=None}".to_string(),
BlockKind::ListItem { task: Some(t) } => format!(
"ListItem{{task=Some(state={:?}, state_at={})}}",
t.state, t.state_at
),
BlockKind::Quote { alert, alert_title } => {
format!("Quote{{alert={alert:?}, alert_title={alert_title:?}}}")
}
BlockKind::Table { aligns, rows } => {
let mut s = format!("Table{{aligns={aligns:?}, rows=[");
for (ri, row) in rows.iter().enumerate() {
if ri > 0 {
s.push_str(", ");
}
s.push('[');
for (ci, cell) in row.iter().enumerate() {
if ci > 0 {
s.push_str(", ");
}
write!(s, "{}:{}", fmt_range(cell), snippet(&src[cell.clone()])).unwrap();
}
s.push(']');
}
s.push_str("]}");
s
}
BlockKind::Html { tag, body_spans } => {
let spans: Vec<String> = body_spans
.iter()
.map(|r| format!("{}:{}", fmt_range(r), snippet(&src[r.clone()])))
.collect();
format!(
"Html{{tag={tag:?}, body_spans=[{}], text={}}}",
spans.join(", "),
snippet(&html_body_text(body_spans, src))
)
}
BlockKind::HtmlTable { body_spans, rows } => {
let spans: Vec<String> = body_spans
.iter()
.map(|r| format!("{}:{}", fmt_range(r), snippet(&src[r.clone()])))
.collect();
let mut s = format!("HtmlTable{{body_spans=[{}], rows=[", spans.join(", "));
for (ri, row) in rows.iter().enumerate() {
if ri > 0 {
s.push_str(", ");
}
s.push('[');
for (ci, cell) in row.iter().enumerate() {
if ci > 0 {
s.push_str(", ");
}
write!(
s,
"{}:{}(header={}, align={:?})",
fmt_range(&cell.inner),
snippet(&html_body_text_in(body_spans, src, &cell.inner)),
cell.header,
cell.align,
)
.unwrap();
}
s.push(']');
}
s.push_str("]}");
s
}
BlockKind::Details {
open_attr,
summary,
glued_body,
} => {
let gb = match glued_body {
Some(r) => format!("Some({}:{})", fmt_range(r), snippet(&src[r.clone()])),
None => "None".to_string(),
};
format!(
"Details{{open_attr={open_attr}, summary={summary:?}, glued_body={gb}}}"
)
}
BlockKind::ThematicBreak => "ThematicBreak".to_string(),
}
}
fn dump_block(
b: &Block,
src: &str,
events: &[(Event<'_>, Range<usize>)],
depth: usize,
out: &mut String,
) {
let indent = " ".repeat(depth);
writeln!(
out,
"{indent}{} src={} text={}",
fmt_kind(&b.kind, src, events),
fmt_range(&b.src),
snippet(&src[b.src.clone()])
)
.unwrap();
for c in &b.children {
dump_block(c, src, events, depth + 1, out);
}
}
fn dump_case(cfg: &Config, name: &str, raw_src: &str, out: &mut String) {
let pre_src = pre_src_for(cfg, raw_src);
let doc = Doc::parse(&pre_src);
writeln!(out, "=== {name} ===").unwrap();
writeln!(out, "-- BLOCKS ({}) --", doc.blocks.len()).unwrap();
for b in &doc.blocks {
dump_block(b, &pre_src, &doc.events, 0, out);
}
writeln!(out).unwrap();
}
fn dump_all(cfg: &Config) -> String {
let mut out = String::new();
for (name, src) in all_cases() {
dump_case(cfg, &name, &src, &mut out);
}
out
}
#[test]
fn markdown_model_snapshot_default() {
assert_matches_snapshot(snapshot_path(), "model", &dump_all(&Config::default()));
}
#[test]
fn markdown_model_snapshot_is_deterministic_within_a_process() {
let cfg = Config::default();
let a = dump_all(&cfg);
let b = dump_all(&cfg);
assert_eq!(
a, b,
"parsing the golden-snapshot corpus twice in the same process produced different output \
— some part of Doc::parse is not deterministic"
);
}
#[test]
fn markdown_model_snapshot_corpus_is_not_empty() {
let cases = all_cases();
assert!(
cases.len() > 100,
"the golden snapshot corpus looks suspiciously small ({} cases) — \
a corpus-gathering call in `all_cases` probably broke",
cases.len()
);
}
struct ReferenceEvents {
inline: Vec<Range<usize>>,
tasks: Vec<Range<usize>>,
code_blocks: Vec<Range<usize>>,
}
fn scan_reference_events(src: &str) -> ReferenceEvents {
let mut out = ReferenceEvents {
inline: Vec::new(),
tasks: Vec::new(),
code_blocks: Vec::new(),
};
let mut stack: Vec<bool> = Vec::new();
for (ev, range) in Parser::new_ext(src, model::parse_options()).into_offset_iter() {
let suppressed_here = stack.last().copied().unwrap_or(false);
match &ev {
Event::Start(tag) => {
if matches!(tag, Tag::CodeBlock(_)) && !suppressed_here {
out.code_blocks.push(range.clone());
}
stack.push(suppressed_here || is_unmodeled_container_tag(tag));
continue;
}
Event::End(_) => {
stack.pop();
continue;
}
_ => {}
}
if suppressed_here {
continue;
}
match &ev {
Event::TaskListMarker(_) => out.tasks.push(range),
other if is_stray_inline_leaf(other) => out.inline.push(range),
_ => {}
}
}
out
}
fn leaf_ranges(blocks: &[Block], out: &mut Vec<Range<usize>>) {
for b in blocks {
let is_leaf = matches!(
b.kind,
BlockKind::Heading { .. }
| BlockKind::Paragraph { .. }
| BlockKind::CodeBlock { .. }
| BlockKind::Table { .. }
| BlockKind::Html { .. }
| BlockKind::HtmlTable { .. }
| BlockKind::ThematicBreak
);
if is_leaf {
out.push(b.src.clone());
}
if matches!(b.kind, BlockKind::Details { .. }) {
out.extend(details_tag_ranges(b));
}
leaf_ranges(&b.children, out);
}
}
fn details_tag_ranges(b: &Block) -> Vec<Range<usize>> {
let mut out = Vec::new();
match (b.children.first(), b.children.last()) {
(Some(first), Some(last)) => {
if b.src.start < first.src.start {
out.push(b.src.start..first.src.start);
}
if last.src.end < b.src.end {
out.push(last.src.end..b.src.end);
}
}
_ => out.push(b.src.clone()),
}
out
}
fn task_state_positions(blocks: &[Block], out: &mut Vec<usize>) {
for b in blocks {
if let BlockKind::ListItem { task: Some(t) } = &b.kind {
out.push(t.state_at);
}
task_state_positions(&b.children, out);
}
}
fn code_block_srcs(blocks: &[Block], out: &mut Vec<Range<usize>>) {
for b in blocks {
if let BlockKind::CodeBlock { .. } = &b.kind {
out.push(b.src.clone());
}
code_block_srcs(&b.children, out);
}
}
fn excerpt(src: &str, r: &Range<usize>) -> String {
const CAP: usize = 60;
let slice = &src[r.clone()];
if slice.chars().count() <= CAP {
format!("{slice:?}")
} else {
let truncated: String = slice.chars().take(CAP).collect();
format!("{truncated:?}…")
}
}
#[test]
fn model_covers_every_inline_event_across_the_full_corpus() {
let cfg = Config::default();
let mut violations = Vec::new();
let mut total_checked = 0usize;
for (name, raw) in all_cases() {
let pre_src = pre_src_for(&cfg, &raw);
let doc = Doc::parse(&pre_src);
let mut leaves = Vec::new();
leaf_ranges(&doc.blocks, &mut leaves);
let reference = scan_reference_events(&pre_src);
for r in &reference.inline {
total_checked += 1;
let covered = leaves.iter().any(|l| l.start <= r.start && r.end <= l.end);
if !covered {
violations.push(format!(
"{name}: inline event {r:?} {} is not covered by any leaf block",
excerpt(&pre_src, r)
));
}
}
}
assert!(
total_checked > 500,
"the corpus's inline-event count looks suspiciously small ({total_checked}) — \
a corpus-gathering call probably broke"
);
assert!(
violations.is_empty(),
"{} inline event(s) not covered by any leaf block:\n{}",
violations.len(),
violations.join("\n")
);
}
#[test]
fn model_covers_every_task_marker_across_the_full_corpus() {
let cfg = Config::default();
let mut violations = Vec::new();
let mut total_checked = 0usize;
for (name, raw) in all_cases() {
let pre_src = pre_src_for(&cfg, &raw);
let doc = Doc::parse(&pre_src);
let mut recorded = Vec::new();
task_state_positions(&doc.blocks, &mut recorded);
recorded.sort_unstable();
let mut expected: Vec<usize> = scan_reference_events(&pre_src)
.tasks
.iter()
.map(|r| r.start + 1)
.collect();
expected.sort_unstable();
total_checked += expected.len();
if recorded != expected {
violations.push(format!(
"{name}: expected task marker positions {expected:?}, model recorded {recorded:?}"
));
}
}
assert!(
total_checked > 20,
"the corpus's task marker count looks suspiciously small ({total_checked}) — \
a corpus-gathering call probably broke"
);
assert!(
violations.is_empty(),
"{} case(s) with missing or extra task marker(s):\n{}",
violations.len(),
violations.join("\n")
);
}
#[test]
fn model_covers_every_code_block_start_across_the_full_corpus() {
let cfg = Config::default();
let mut violations = Vec::new();
let mut total_checked = 0usize;
for (name, raw) in all_cases() {
let pre_src = pre_src_for(&cfg, &raw);
let doc = Doc::parse(&pre_src);
let mut modeled = Vec::new();
code_block_srcs(&doc.blocks, &mut modeled);
let reference = scan_reference_events(&pre_src);
for r in &reference.code_blocks {
total_checked += 1;
let covered = modeled.iter().any(|m| m.start <= r.start && r.end <= m.end);
if !covered {
violations.push(format!(
"{name}: code block start {r:?} {} is not modeled",
excerpt(&pre_src, r)
));
}
}
}
assert!(
total_checked > 20,
"the corpus's code block count looks suspiciously small ({total_checked}) — \
a corpus-gathering call probably broke"
);
assert!(
violations.is_empty(),
"{} code block(s) not modeled:\n{}",
violations.len(),
violations.join("\n")
);
}