use pulldown_cmark::{Event, Options, Parser, Tag};
use crate::error::Error;
#[derive(Debug, PartialEq)]
pub struct MdBlock {
pub line: usize,
pub kind: MdKind,
}
#[derive(Debug, PartialEq)]
pub enum MdKind {
Title(String),
H2(String),
H3(String),
Appendix,
Abstract,
Fence { info: String, body: String },
Table(Vec<String>),
Directive(String),
Para(Vec<String>),
DisplayMath(String),
}
pub fn prepass(md: &str) -> Result<(Vec<String>, Vec<usize>, Vec<(usize, String, usize)>), Error> {
let lines: Vec<&str> = md.lines().collect();
let mut kept: Vec<bool> = vec![true; lines.len()];
let mut directives: Vec<(usize, String, usize)> = Vec::new();
let mut infence = false;
for (n, l) in lines.iter().enumerate() {
let s = l.trim();
if s.starts_with("```") {
infence = !infence;
continue;
}
if infence {
continue;
}
if s.starts_with('>') {
kept[n] = false;
continue;
}
if s.starts_with("%% table") {
let mut j = n + 1;
while j < lines.len() && lines[j].trim().is_empty() {
j += 1;
}
if j < lines.len() && lines[j].trim().starts_with('|') {
kept[n] = false;
let mut runs = 0;
let mut k = 0;
while k < j {
if kept[k] && lines[k].trim().starts_with('|') {
runs += 1;
while k < j && kept[k] && lines[k].trim().starts_with('|') {
k += 1;
}
} else {
k += 1;
}
}
directives.push((runs, crate::doc::fence_attr_str(s).to_string(), n + 1));
}
continue;
}
if !s.starts_with("[^") && s.starts_with('[') {
if let Some(col) = s.find("]:") {
if !s[1..col].contains([' ', '\n', '[']) {
return Err(Error::new(
"reference definitions `[label]: ...` are not in the subset (write the text inline)",
n + 1,
s.to_string(),
).with_col(Some(1)));
}
}
}
}
let mut stripped = Vec::new();
let mut orig = Vec::new();
for (n, l) in lines.iter().enumerate() {
if kept[n] {
stripped.push(l.to_string());
orig.push(n + 1);
}
}
Ok((stripped, orig, directives))
}
pub fn blocks(md: &str) -> Result<Vec<MdBlock>, Error> {
let (stripped, orig, directives) = prepass(md)?;
let text = stripped.join("\n");
let mut line_off: Vec<usize> = Vec::with_capacity(stripped.len());
let mut o = 0;
for l in &stripped {
line_off.push(o);
o += l.len() + 1;
}
let line_of = |off: usize| -> usize {
match line_off.binary_search(&off) {
Ok(n) => n,
Err(n) => n.saturating_sub(1),
}
};
let is_blank = |k: usize| -> bool {
stripped.get(k).map(|l| l.trim().is_empty()).unwrap_or(false)
};
let mut out: Vec<MdBlock> = Vec::new();
let mut para: Vec<String> = Vec::new();
let mut para_tail: usize = 0;
let mut para_line: usize = 0;
let flush = |out: &mut Vec<MdBlock>, para: &mut Vec<String>, pline: &mut usize| {
if !para.is_empty() {
out.push(MdBlock { line: *pline, kind: MdKind::Para(std::mem::take(para)) });
}
};
let feed = |out: &mut Vec<MdBlock>,
para: &mut Vec<String>,
tail: &mut usize,
pline: &mut usize,
run: &[(usize, String)]| {
if run.is_empty() {
return;
}
if !para.is_empty() && (tail.saturating_add(1)..run[0].0).any(|k| is_blank(k)) {
out.push(MdBlock { line: *pline, kind: MdKind::Para(std::mem::take(para)) });
}
for (k, l) in run {
let t = l.trim();
if !t.is_empty() {
if para.is_empty() {
*pline = orig.get(*k).copied().unwrap_or(1);
}
para.push(t.to_string());
*tail = *k;
}
}
};
enum Kind {
Para,
Heading,
Code,
Table,
Other,
}
let mut stack: Vec<(Kind, usize)> = Vec::new();
let mut spans: Vec<(Kind, usize, usize)> = Vec::new();
for (ev, range) in Parser::new_ext(&text, Options::ENABLE_TABLES).into_offset_iter() {
match ev {
Event::Start(tag) => {
let k = match tag {
Tag::Paragraph => Kind::Para,
Tag::Heading { .. } => Kind::Heading,
Tag::CodeBlock(_) => Kind::Code,
Tag::Table(_) => Kind::Table,
_ => Kind::Other,
};
stack.push((k, range.start));
}
Event::End(_) => {
if let Some((k, s)) = stack.pop() {
if stack.is_empty() {
spans.push((k, s, range.end));
}
}
}
_ => {}
}
}
for (kind, s, e) in spans {
let base = line_of(s);
let cover: Vec<(usize, String)> = text[s..e]
.split('\n')
.enumerate()
.map(|(k, l)| (base + k, l.to_string()))
.collect();
let ln = orig.get(base).copied().unwrap_or(1);
match kind {
Kind::Other => {
feed(&mut out, &mut para, &mut para_tail, &mut para_line, &cover);
}
Kind::Para => {
let mut seg: Vec<(usize, String)> = Vec::new();
let mut run: Vec<(usize, String)> = Vec::new();
let mut runs: Vec<(Vec<(usize, String)>, Vec<(usize, String)>)> = Vec::new();
for (k, l) in cover {
if l.trim().starts_with('|') {
if !seg.is_empty() {
runs.push((std::mem::take(&mut seg), Vec::new()));
}
run.push((k, l.trim().to_string()));
} else {
if !run.is_empty() {
runs.push((Vec::new(), std::mem::take(&mut run)));
}
seg.push((k, l));
}
}
if !run.is_empty() {
runs.push((Vec::new(), run));
}
if !seg.is_empty() {
runs.push((seg, Vec::new()));
}
for (textseg, tablerun) in runs {
if !tablerun.is_empty() {
flush(&mut out, &mut para, &mut para_line);
let tline = orig.get(tablerun[0].0).copied().unwrap_or(ln);
let rows = tablerun.into_iter().map(|(_, r)| r).collect();
out.push(MdBlock { line: tline, kind: MdKind::Table(rows) });
} else {
feed(&mut out, &mut para, &mut para_tail, &mut para_line, &textseg);
}
}
}
Kind::Heading => {
let first = cover[0].1.trim().to_string();
if let Some(rest) = first.strip_prefix("### ") {
flush(&mut out, &mut para, &mut para_line);
out.push(MdBlock { line: ln, kind: MdKind::H3(rest.to_string()) });
} else if let Some(rest) = first.strip_prefix("## ") {
flush(&mut out, &mut para, &mut para_line);
if rest.strip_prefix("Appendix").is_some() {
out.push(MdBlock { line: ln, kind: MdKind::Appendix });
} else if rest.starts_with("Abstract") {
out.push(MdBlock { line: ln, kind: MdKind::Abstract });
} else {
out.push(MdBlock { line: ln, kind: MdKind::H2(rest.to_string()) });
}
} else if let Some(rest) = first.strip_prefix("# ") {
if let Some(MdBlock { kind: MdKind::Title(_), .. }) = out.last() {
out.pop();
}
out.push(MdBlock { line: ln, kind: MdKind::Title(rest.to_string()) });
} else {
feed(&mut out, &mut para, &mut para_tail, &mut para_line, &cover);
}
}
Kind::Code => {
let first = cover[0].1.trim().to_string();
if first.starts_with("```") {
flush(&mut out, &mut para, &mut para_line);
let mut body = Vec::new();
for (_, l) in cover.iter().skip(1) {
let t = l.trim();
if !t.is_empty() && t.chars().all(|c| c == '`') {
break;
}
body.push(l.to_string());
}
out.push(MdBlock {
line: ln,
kind: MdKind::Fence { info: first, body: body.join("\n") },
});
} else {
feed(&mut out, &mut para, &mut para_tail, &mut para_line, &cover);
}
}
Kind::Table => {
flush(&mut out, &mut para, &mut para_line);
let mut rows: Vec<String> =
cover.iter().map(|(_, l)| l.trim().to_string()).collect();
while rows.last().map(|l| l.is_empty()).unwrap_or(false) {
rows.pop();
}
out.push(MdBlock { line: ln, kind: MdKind::Table(rows) });
}
}
}
flush(&mut out, &mut para, &mut para_line);
let mut sorted = directives;
sorted.sort_by_key(|(seq, _, _)| *seq);
let mut diri = 0usize;
let mut table_idx = 0usize;
let mut merged: Vec<MdBlock> = Vec::with_capacity(out.len() + sorted.len());
for b in out {
if matches!(b.kind, MdKind::Table(_)) {
while diri < sorted.len() && sorted[diri].0 == table_idx {
merged.push(MdBlock {
line: sorted[diri].2,
kind: MdKind::Directive(sorted[diri].1.clone()),
});
diri += 1;
}
table_idx += 1;
}
merged.push(b);
}
let mut final_blocks: Vec<MdBlock> = Vec::with_capacity(merged.len());
for b in merged {
match b.kind {
MdKind::Para(ls) => {
let joined = ls.join(" ");
let t = joined.trim();
if t.starts_with("$$") && t.ends_with("$$") && t.len() >= 4 { let inner = t[2..t.len() - 2].trim();
if inner.is_empty() {
return Err(Error::data(
format!("line {}: empty display math `$$$$`", b.line),
String::new(),
));
}
if inner.contains("$$") {
return Err(Error::data(
format!(
"line {}: display math holds a second `$$`; one span per paragraph",
b.line
),
String::new(),
));
}
final_blocks.push(MdBlock { line: b.line, kind: MdKind::DisplayMath(inner.to_string()) });
} else {
if t.contains("$$") {
return Err(Error::data(
format!(
"line {}: `$$` outside a whole-paragraph span is not in the subset; display math must fill its own paragraph",
b.line
),
String::new(),
));
}
final_blocks.push(MdBlock { line: b.line, kind: MdKind::Para(ls) });
}
}
_ => final_blocks.push(b),
}
}
Ok(final_blocks)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_math_whole_paragraph() {
let b = blocks("# T\n\n$$x^2$$\n").unwrap();
assert!(matches!(b[1].kind, MdKind::DisplayMath(_)));
}
#[test]
fn display_math_stray_dollars_error() {
let err = blocks("# T\n\nsee $$x$$ here\n").unwrap_err();
assert!(err.msg.contains("own paragraph"), "got {}", err.msg);
}
#[test]
fn display_math_empty_and_double_error() {
let err = blocks("# T\n\n$$$$\n").unwrap_err();
assert!(err.msg.contains("empty"), "got {}", err.msg);
let err = blocks("# T\n\n$$a$$b$$\n").unwrap_err();
assert!(err.msg.contains("second"), "got {}", err.msg);
}
#[test]
fn error_refdef_names_line() {
let e = blocks("# T\n\n[lab]: /url\n").unwrap_err();
assert!(e.msg.contains("reference definitions"), "got: {e}");
assert_eq!(e.line, 3, "got: {e}");
assert!(e.echo.contains("[lab]: /url"), "got: {e}");
}
#[test]
fn blocks_carry_original_lines() {
let md = "# T\n\n> dropped\n\n## S\n\n%% table {pos=bottom}\n\n| A |\n|---|\n";
let bs = blocks(md).unwrap();
let lines: Vec<usize> = bs.iter().map(|b| b.line).collect();
assert!(lines.contains(&1), "title: {lines:?}");
assert!(lines.contains(&5), "section: {lines:?}");
assert!(lines.contains(&7), "directive orig line: {lines:?}");
assert!(lines.contains(&9), "table orig line: {lines:?}");
}
}