#[derive(Debug, PartialEq)]
enum Block {
Title(String), H2(String), H3(String), Appendix, Abstract, Fence { lang: String, attr: String, body: String },
Table(Vec<String>), Directive(String), Para(String), }
fn hand_parse(md: &str) -> Vec<Block> {
let lines: Vec<&str> = md.lines().collect();
let mut out = Vec::new();
let mut para: Vec<String> = Vec::new();
let mut i = 0;
let flush = |out: &mut Vec<Block>, para: &mut Vec<String>| {
if !para.is_empty() {
out.push(Block::Para(para.join(" ")));
para.clear();
}
};
while i < lines.len() {
let s = lines[i].trim();
if s.is_empty() {
flush(&mut out, &mut para);
i += 1;
continue;
}
if s.starts_with('>') {
i += 1;
continue;
}
if s.starts_with("```") {
flush(&mut out, &mut para);
let info = s.trim_start_matches('`');
let mut sp = info.splitn(2, char::is_whitespace);
let (lang, attr) = (
sp.next().unwrap_or("").to_string(),
sp.next().unwrap_or("").trim().to_string(),
);
let mut body = Vec::new();
let mut j = i + 1;
while j < lines.len() && !lines[j].trim().starts_with("```") {
body.push(lines[j].to_string());
j += 1;
}
out.push(Block::Fence { lang, attr, body: body.join("\n") });
i = j + 1;
continue;
}
if s.starts_with("%% table") {
let mut j = i + 1;
while j < lines.len() && lines[j].trim().is_empty() {
j += 1;
}
if j < lines.len() && lines[j].trim().starts_with('|') {
out.push(Block::Directive(crate::doc::fence_attr_str(s).to_string()));
i = j;
continue;
}
}
if s.starts_with('|') {
flush(&mut out, &mut para);
let mut rows = Vec::new();
while i < lines.len() && lines[i].trim().starts_with('|') {
rows.push(lines[i].trim().to_string());
i += 1;
}
out.push(Block::Table(rows));
continue;
}
if s.starts_with("## Appendix") {
flush(&mut out, &mut para);
out.push(Block::Appendix);
i += 1;
continue;
}
if s.starts_with("## Abstract") {
flush(&mut out, &mut para);
out.push(Block::Abstract);
i += 1;
continue;
}
if s.starts_with("## ") {
flush(&mut out, &mut para);
out.push(Block::H2(s[3..].to_string()));
i += 1;
continue;
}
if s.starts_with("### ") {
flush(&mut out, &mut para);
out.push(Block::H3(s[4..].to_string()));
i += 1;
continue;
}
if s.starts_with("# ") {
if let Some(Block::Title(_)) = out.last() {
out.pop();
}
out.push(Block::Title(s[2..].to_string()));
i += 1;
continue;
}
para.push(s.to_string());
i += 1;
}
flush(&mut out, &mut para);
out
}
fn pdc_parse(md: &str) -> Result<Vec<Block>, crate::error::Error> {
let mut out = Vec::new();
for b in crate::md::blocks(md)? {
match b.kind {
crate::md::MdKind::Title(t) => {
if let Some(Block::Title(_)) = out.last() {
out.pop();
}
out.push(Block::Title(t));
}
crate::md::MdKind::H2(h) => out.push(Block::H2(h)),
crate::md::MdKind::H3(h) => out.push(Block::H3(h)),
crate::md::MdKind::Appendix => out.push(Block::Appendix),
crate::md::MdKind::Abstract => out.push(Block::Abstract),
crate::md::MdKind::Fence { info, body } => {
let mut sp = info.trim_start_matches('`').splitn(2, char::is_whitespace);
out.push(Block::Fence {
lang: sp.next().unwrap_or("").to_string(),
attr: sp.next().unwrap_or("").trim().to_string(),
body,
});
}
crate::md::MdKind::Table(rows) => out.push(Block::Table(rows)),
crate::md::MdKind::Directive(a) => out.push(Block::Directive(a)),
crate::md::MdKind::Para(ls) => out.push(Block::Para(ls.join(" "))),
crate::md::MdKind::DisplayMath(t) => out.push(Block::Para(format!("$${t}$$"))),
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "# Title Here\n\
\n\
## Abstract\n\
\n\
Abs line one.\n\
Abs line two.\n\
\n\
\n\
## 1. First\n\
\n\
Para with math $x$ and cite [`k`] inline.\n\
\n\
```mermaid {span=column pos=both}\n\
graph TD\n\
%% caption: Cap one.\n\
a[x]-->b[y]\n\
```\n\
\n\
%% table {pos=barrier}\n\
\n\
| A | B |\n\
|---|---|\n\
| 1 | 2 |\n\
| 3 | 4 |\n\
\n\
### 1.1 Sub\n\
\n\
```mermaid {inline}\n\
graph TD\n\
c[x]-->d[y]\n\
```\n\
\n\
## Appendix\n\
\n\
Tail.\n";
#[test]
fn sample_parity() {
let hand = hand_parse(SAMPLE);
let pdc = pdc_parse(SAMPLE).unwrap();
assert_eq!(hand, pdc);
}
fn corpus_path(name: &str) -> Option<String> {
let base = env!("CARGO_MANIFEST_DIR");
let p = std::path::Path::new(base).join(name);
std::fs::read_to_string(p).ok()
}
#[test]
fn corpus_parity() {
for rel in [
"../paper/paper-2026-10-05.md",
"examples/ikat-paper/ikat-paper.md",
"examples/mini.md",
] {
let Some(md) = corpus_path(rel) else {
continue;
};
let hand = hand_parse(&md);
let pdc = pdc_parse(&md).unwrap();
assert_eq!(hand, pdc, "block stream differs for {rel}");
}
}
#[test]
fn latent_cases() {
let md = "# T\n\n%% table {pos=bottom}\n| A |\n|---|\n| 1 |\n";
assert_eq!(hand_parse(md), pdc_parse(md).unwrap());
let md = "# T\n\n%% table {pos=bottom}\n\nJust text.\n";
assert_eq!(hand_parse(md), pdc_parse(md).unwrap());
let md = "# T\n\nfoo\n> dropped\nbar\n";
assert_eq!(hand_parse(md), pdc_parse(md).unwrap());
let md = "# T\n\n## X ##\n\nBody.\n";
assert_eq!(hand_parse(md), pdc_parse(md).unwrap());
let md = "# T\n\n#NoSpace here\n";
assert_eq!(hand_parse(md), pdc_parse(md).unwrap());
let md = "# T\n\n#### deep\n\nBody.\n";
assert_eq!(hand_parse(md), pdc_parse(md).unwrap());
let md = "# T\n\n~~~mermaid\ngraph TD\na-->b\n~~~\n";
assert_eq!(hand_parse(md), pdc_parse(md).unwrap());
let md = "# T\n\nfoo\n# U\nbar\n";
assert_eq!(hand_parse(md), pdc_parse(md).unwrap());
let md = "# T\n\n| just a line\n";
assert_eq!(hand_parse(md), pdc_parse(md).unwrap());
let md = "# T\n\nfoo\n| A |\n|---|\n| 1 |\n";
assert_eq!(hand_parse(md), pdc_parse(md).unwrap());
let md = "# T\n\n## Appendix B\n\nTail.\n";
assert_eq!(hand_parse(md), pdc_parse(md).unwrap());
let md = "# T\n\nA *b* and `c` line.\n";
assert_eq!(hand_parse(md), pdc_parse(md).unwrap());
let md = "# T\n\n[lab]: /url\n";
assert!(pdc_parse(md).is_err());
}
}