use pandoc_ast::{Block, Cell, Inline, MathType, Pandoc, Row, TableBody, TableFoot, TableHead};
use crate::parser::Region;
pub fn regions_from_pandoc(doc: &Pandoc) -> Vec<Region> {
let mut regions = Vec::new();
for (i, block) in doc.blocks.iter().enumerate() {
if i > 0 {
push_block_separator(&mut regions);
}
extract_block(block, &mut regions);
}
regions
}
fn push_block_separator(regions: &mut Vec<Region>) {
if regions.is_empty() {
return;
}
if matches!(regions.last(), Some(Region::BlankLines(_))) {
return;
}
regions.push(Region::BlankLines("\n".to_string()));
}
pub fn regions_from_pandoc_json(json: &str) -> Result<Vec<Region>, String> {
let doc: Pandoc = serde_json::from_str(json)
.map_err(|e| format!("failed to deserialize pandoc AST JSON: {e}"))?;
Ok(regions_from_pandoc(&doc))
}
fn extract_block(block: &Block, regions: &mut Vec<Region>) {
match block {
Block::Para(inlines) | Block::Plain(inlines) => {
extract_para_inlines(inlines, regions);
}
Block::Header(_level, _attr, inlines) => {
let text = extract_inlines(inlines);
let title = text.trim();
if !title.is_empty() {
regions.push(Region::Structure(format!("{title}\n")));
}
}
Block::CodeBlock(attr, code) => {
let lang = code_lang_from_attr(attr);
let body = if code.ends_with('\n') {
code.clone()
} else if code.is_empty() {
String::new()
} else {
format!("{code}\n")
};
let (header, footer) = code_fence_frame(lang.as_deref());
regions.push(Region::Code {
lang,
header,
body,
footer,
});
}
Block::RawBlock(_, raw) => {
let s = if raw.ends_with('\n') {
raw.clone()
} else {
format!("{raw}\n")
};
regions.push(Region::Structure(s));
}
Block::BlockQuote(blocks) => {
for (i, b) in blocks.iter().enumerate() {
if i > 0 {
push_block_separator(regions);
}
extract_quoted_block(b, regions);
}
}
Block::BulletList(items) => {
for (i, item) in items.iter().enumerate() {
if i > 0 {
ensure_trailing_newline(regions);
}
extract_list_item("- ", item, regions);
}
}
Block::OrderedList(_, items) => {
for (i, item) in items.iter().enumerate() {
if i > 0 {
ensure_trailing_newline(regions);
}
let marker = format!("{}. ", i + 1);
extract_list_item(&marker, item, regions);
}
}
Block::DefinitionList(defs) => {
for (term, definitions) in defs {
let term_text = extract_inlines(term);
if !term_text.trim().is_empty() {
regions.push(Region::Structure(format!("{}\n", term_text.trim())));
}
for def in definitions {
for b in def {
extract_block(b, regions);
}
}
}
}
Block::Table(_attr, _caption, _cols, head, bodies, foot) => {
let rendered = render_table_structure(head, bodies, foot);
if !rendered.is_empty() {
regions.push(Region::Structure(rendered));
}
}
Block::HorizontalRule => {
regions.push(Region::Structure("---\n".to_string()));
}
Block::Div(_, blocks) => {
for b in blocks {
extract_block(b, regions);
}
}
Block::Figure(_, _, blocks) => {
for b in blocks {
extract_block(b, regions);
}
}
Block::Null => {}
Block::LineBlock(lines) => {
for line in lines {
let text = extract_inlines(line);
regions.push(Region::Structure(format!("{text}\n")));
}
}
}
}
fn code_lang_from_attr(attr: &pandoc_ast::Attr) -> Option<String> {
let (_id, classes, _kvs) = attr;
if let Some(c) = classes.first().cloned().filter(|c| !c.is_empty()) {
return Some(c);
}
for (k, v) in _kvs {
if k.eq_ignore_ascii_case("language") && !v.is_empty() {
return Some(v.clone());
}
}
None
}
fn code_fence_frame(lang: Option<&str>) -> (String, String) {
let header = match lang {
Some(l) if !l.is_empty() => format!("```{l}\n"),
_ => "```\n".to_string(),
};
(header, "```\n".to_string())
}
fn ensure_trailing_newline(regions: &mut Vec<Region>) {
match regions.last() {
Some(Region::Structure(s) | Region::Prose(s) | Region::BlankLines(s))
if s.ends_with('\n') => {}
Some(Region::Code { footer, .. }) if footer.ends_with('\n') => {}
Some(_) => regions.push(Region::Structure("\n".to_string())),
None => {}
}
}
fn extract_quoted_block(block: &Block, regions: &mut Vec<Region>) {
match block {
Block::Para(inlines) | Block::Plain(inlines) => {
regions.push(Region::Structure("> ".to_string()));
extract_para_inlines(inlines, regions);
ensure_trailing_newline(regions);
}
other => {
regions.push(Region::Structure("> ".to_string()));
extract_block(other, regions);
}
}
}
fn extract_list_item(marker: &str, item: &[Block], regions: &mut Vec<Region>) {
regions.push(Region::Structure(marker.to_string()));
for (i, block) in item.iter().enumerate() {
if i == 0 {
match block {
Block::Para(inlines) | Block::Plain(inlines) => {
extract_para_inlines(inlines, regions);
ensure_trailing_newline(regions);
}
other => extract_block(other, regions),
}
} else {
push_block_separator(regions);
extract_block(block, regions);
}
}
}
fn render_table_structure(head: &TableHead, bodies: &[TableBody], foot: &TableFoot) -> String {
let mut rows: Vec<Vec<String>> = Vec::new();
let (_hattr, head_rows) = head;
for row in head_rows {
rows.push(row_cells(row));
}
let head_count = head_rows.len();
for body in bodies {
let (_battr, _rhc, intermediate, body_rows) = body;
for row in intermediate {
rows.push(row_cells(row));
}
for row in body_rows {
rows.push(row_cells(row));
}
}
let (_fattr, foot_rows) = foot;
for row in foot_rows {
rows.push(row_cells(row));
}
if rows.is_empty() {
return String::new();
}
let ncols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
if ncols == 0 {
return String::new();
}
for r in &mut rows {
while r.len() < ncols {
r.push(String::new());
}
}
let sep_after = if head_count > 0 { head_count - 1 } else { 0 };
let mut out = String::new();
for (i, row) in rows.iter().enumerate() {
out.push_str("| ");
out.push_str(
&row.iter()
.map(|c| c.replace('|', "\\|"))
.collect::<Vec<_>>()
.join(" | "),
);
out.push_str(" |\n");
if i == sep_after {
out.push('|');
for _ in 0..ncols {
out.push_str(" --- |");
}
out.push('\n');
}
}
out
}
fn row_cells(row: &Row) -> Vec<String> {
let (_attr, cells) = row;
cells.iter().map(cell_text).collect()
}
fn cell_text(cell: &Cell) -> String {
let (_attr, _align, _rs, _cs, blocks) = cell;
let mut parts = Vec::new();
for b in blocks {
match b {
Block::Plain(inlines) | Block::Para(inlines) => {
parts.push(extract_inlines(inlines));
}
Block::CodeBlock(_, code) => parts.push(code.clone()),
Block::RawBlock(_, raw) => parts.push(raw.clone()),
_ => {}
}
}
parts.join(" ").trim().to_string()
}
fn is_math_only_para(inlines: &[Inline]) -> bool {
let mut saw_math = false;
for inline in inlines {
match inline {
Inline::Math(..) => saw_math = true,
Inline::Space | Inline::SoftBreak | Inline::LineBreak => {}
_ => return false,
}
}
saw_math
}
fn format_math(ty: &MathType, body: &str) -> String {
match ty {
MathType::DisplayMath => {
let b = body.trim();
if b.is_empty() {
"$$\n".to_string()
} else if b.contains('\n') {
format!("$$\n{b}\n$$\n")
} else {
format!("$${b}$$\n")
}
}
MathType::InlineMath => format!("${body}$"),
}
}
fn flush_prose_buf(prose: &mut String, regions: &mut Vec<Region>, trim_trailing: bool) {
if prose.chars().all(|c| c.is_whitespace()) {
prose.clear();
return;
}
let s = if trim_trailing {
prose.trim_end()
} else {
prose.as_str()
};
if !s.is_empty() {
regions.push(Region::Prose(s.to_string()));
}
prose.clear();
}
fn extract_para_inlines(inlines: &[Inline], regions: &mut Vec<Region>) {
if inlines.is_empty() {
return;
}
if is_math_only_para(inlines) {
let mut out = String::new();
for inline in inlines {
if let Inline::Math(ty, body) = inline {
out.push_str(&format_math(ty, body));
}
}
if !out.is_empty() {
if !out.ends_with('\n') {
out.push('\n');
}
regions.push(Region::Structure(out));
}
return;
}
let mut prose = String::new();
walk_para_inlines(inlines, regions, &mut prose);
flush_prose_buf(&mut prose, regions, true);
}
fn walk_para_inlines(inlines: &[Inline], regions: &mut Vec<Region>, prose: &mut String) {
for inline in inlines {
match inline {
Inline::Math(ty, body) => {
let need_lead = !prose.is_empty() && !prose.ends_with('\n');
flush_prose_buf(prose, regions, true);
let mut s = String::new();
if matches!(ty, MathType::DisplayMath) {
if need_lead {
}
s.push_str(&format_math(ty, body));
if !s.ends_with('\n') {
s.push('\n');
}
} else {
if need_lead {
s.push(' ');
}
s.push_str(&format_math(ty, body));
s.push(' ');
}
regions.push(Region::Structure(s));
}
Inline::Code(_, code) => {
let need_lead = !prose.is_empty() && !prose.ends_with('\n');
flush_prose_buf(prose, regions, true);
let mut s = String::new();
if need_lead {
s.push(' ');
}
s.push('`');
s.push_str(code);
s.push_str("` ");
regions.push(Region::Structure(s));
}
Inline::Str(s) => prose.push_str(s),
Inline::Space => prose.push(' '),
Inline::SoftBreak => prose.push(' '),
Inline::LineBreak => prose.push('\n'),
Inline::Emph(children)
| Inline::Strong(children)
| Inline::Underline(children)
| Inline::Strikeout(children)
| Inline::Superscript(children)
| Inline::Subscript(children)
| Inline::SmallCaps(children)
| Inline::Quoted(_, children)
| Inline::Span(_, children)
| Inline::Cite(_, children)
| Inline::Link(_, children, _)
| Inline::Image(_, children, _) => {
walk_para_inlines(children, regions, prose);
}
Inline::RawInline(_, raw) => prose.push_str(raw),
Inline::Note(_) => {}
}
}
}
fn extract_inlines(inlines: &[Inline]) -> String {
let mut result = String::new();
flatten_inlines(inlines, &mut result);
result
}
fn flatten_inlines(inlines: &[Inline], out: &mut String) {
for inline in inlines {
match inline {
Inline::Str(s) => out.push_str(s),
Inline::Space => out.push(' '),
Inline::SoftBreak => out.push(' '),
Inline::LineBreak => out.push('\n'),
Inline::Code(_, code) => {
out.push('`');
out.push_str(code);
out.push('`');
}
Inline::Math(ty, body) => out.push_str(&format_math(ty, body)),
Inline::Emph(c)
| Inline::Strong(c)
| Inline::Underline(c)
| Inline::Strikeout(c)
| Inline::Superscript(c)
| Inline::Subscript(c)
| Inline::SmallCaps(c)
| Inline::Quoted(_, c)
| Inline::Span(_, c)
| Inline::Cite(_, c)
| Inline::Link(_, c, _)
| Inline::Image(_, c, _) => flatten_inlines(c, out),
Inline::RawInline(_, raw) => out.push_str(raw),
Inline::Note(_) => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mixed_doc_json() -> &'static str {
include_str!("../../../tests/fixtures/pandoc_ast/mixed_minimal.json")
}
#[test]
fn ast_classifies_prose_heading_code_table_from_node_kinds() {
let regions = regions_from_pandoc_json(mixed_doc_json()).expect("valid fixture JSON");
let prose: Vec<_> = regions
.iter()
.filter(|r| matches!(r, Region::Prose(_)))
.collect();
assert!(
prose.len() >= 1,
"expected ≥1 prose region from Para nodes, got {regions:?}"
);
assert!(
prose
.iter()
.any(|r| matches!(r, Region::Prose(s) if s.contains("Hello"))),
"expected paragraph prose, got {prose:?}"
);
let has_heading_structure = regions
.iter()
.any(|r| matches!(r, Region::Structure(s) if s.contains("Title")));
assert!(
has_heading_structure,
"Header node must be Structure (not Prose): {regions:?}"
);
assert!(
!regions
.iter()
.any(|r| matches!(r, Region::Prose(s) if s.contains("Title"))),
"Header title must not be Prose: {regions:?}"
);
let code = regions.iter().find_map(|r| match r {
Region::Code {
lang,
header,
body,
footer,
} => Some((lang.clone(), header.clone(), body.clone(), footer.clone())),
_ => None,
});
let (lang, header, body, footer) = code.expect("CodeBlock must become Region::Code");
assert_eq!(lang.as_deref(), Some("python"));
assert!(
header.starts_with("```") && header.contains("python"),
"fence header from Attr lang: {header:?}"
);
assert!(footer.starts_with("```"), "fence footer: {footer:?}");
assert!(body.contains("print(1)"), "code body: {body}");
let has_table = regions.iter().any(|r| {
matches!(r, Region::Structure(s) if s.contains('|') && (s.contains('a') || s.contains("---")))
});
assert!(
has_table,
"Table must be pipe Structure from cells: {regions:?}"
);
for r in ®ions {
if let Region::Prose(s) = r {
assert!(
!s.contains("print(1)") && !s.lines().any(|l| l.trim().starts_with('|')),
"structure leaked into prose: {s}"
);
}
}
}
#[test]
fn ast_table_list_quote_from_structure_fixture() {
let json = include_str!("../../../tests/fixtures/pandoc_ast/structure_blocks.json");
let regions = regions_from_pandoc_json(json).expect("structure_blocks.json");
let table = regions.iter().find_map(|r| match r {
Region::Structure(s) if s.contains('|') && s.contains("---") => Some(s.as_str()),
_ => None,
});
let table = table.expect(&format!("pipe table structure: {regions:?}"));
assert!(table.contains('a') && table.contains('b') && table.contains('1'));
assert!(
!regions
.iter()
.any(|r| matches!(r, Region::Prose(p) if p.contains('|') && p.contains("---"))),
"table not Prose: {regions:?}"
);
assert!(
regions.iter().any(
|r| matches!(r, Region::Structure(s) if s.trim() == "-" || s.starts_with("- "))
),
"bullet marker: {regions:?}"
);
assert!(
regions
.iter()
.any(|r| matches!(r, Region::Structure(s) if s.starts_with("> "))),
"blockquote marker: {regions:?}"
);
assert!(
regions
.iter()
.any(|r| matches!(r, Region::Prose(p) if p.contains("Intro") || p.contains("End"))),
"body prose: {regions:?}"
);
}
#[test]
fn ast_rejects_invalid_json_explicitly() {
let err = regions_from_pandoc_json("not-json").unwrap_err();
assert!(
err.contains("deserialize") || err.contains("failed"),
"unexpected error: {err}"
);
}
#[test]
fn ast_numbered_header_is_structure_not_prose() {
let json = include_str!("../../../tests/fixtures/pandoc_ast/numbered_heading.json");
let regions = regions_from_pandoc_json(json).expect("numbered_heading.json");
let heading = regions.iter().find_map(|r| match r {
Region::Structure(s) if s.contains("cargo binstall") => Some(s.as_str()),
_ => None,
});
let heading = heading.expect(&format!(
"expected Structure from Header node, got {regions:?}"
));
assert!(
heading.contains("1.") && heading.contains("cargo binstall"),
"Header title text preserved as structure payload: {heading:?}"
);
assert!(
!regions
.iter()
.any(|r| matches!(r, Region::Prose(p) if p.contains("cargo binstall"))),
"Header title must not be Prose (would be sentence-reflowed): {regions:?}"
);
assert!(
regions
.iter()
.any(|r| matches!(r, Region::Prose(p) if p.contains("Hello"))),
"Para nodes remain Prose: {regions:?}"
);
}
#[test]
fn ast_codeblock_and_display_math_not_prose() {
let json = include_str!("../../../tests/fixtures/pandoc_ast/math_code_md.json");
let regions = regions_from_pandoc_json(json).expect("math_code_md.json");
let code = regions
.iter()
.find(|r| matches!(r, Region::Code { body, .. } if body.contains("print")));
match code {
Some(Region::Code {
lang,
header,
body,
footer,
}) => {
assert_eq!(lang.as_deref(), Some("python"));
assert!(
header.contains("```") && header.contains("python"),
"{header}"
);
assert!(footer.contains("```"), "{footer}");
assert!(body.contains("print(1.0)") && body.contains("x = 2."));
}
other => panic!("CodeBlock → Region::Code with fence: {other:?} / {regions:?}"),
}
assert!(
regions.iter().any(|r| {
matches!(r, Region::Structure(s) if s.contains("1.5") || s.contains("x = 1"))
}),
"display math must be Structure: {regions:?}"
);
assert!(
!regions.iter().any(|r| {
matches!(r, Region::Prose(p) if p.contains("1.5") && p.contains("y = 2"))
}),
"display math body must not be Prose: {regions:?}"
);
assert!(
regions.iter().any(|r| {
matches!(r, Region::Structure(s) if s.contains("mc^2") || s.contains("E = mc"))
}),
"inline math → Structure island: {regions:?}"
);
assert!(
!regions
.iter()
.any(|r| matches!(r, Region::Prose(p) if p.contains("mc^2"))),
"inline math payload must not sit in Prose: {regions:?}"
);
assert!(
regions
.iter()
.any(|r| matches!(r, Region::Prose(p) if p.contains("First sentence"))),
"plain prose Para: {regions:?}"
);
}
#[test]
fn ast_latex_equation_and_minted_not_prose() {
let json = include_str!("../../../tests/fixtures/pandoc_ast/math_code_tex.json");
let regions = regions_from_pandoc_json(json).expect("math_code_tex.json");
assert!(
regions.iter().any(
|r| matches!(r, Region::Structure(s) if s.contains("mc^2") || s.contains("E = mc"))
),
"equation DisplayMath → Structure: {regions:?}"
);
assert!(
!regions
.iter()
.any(|r| matches!(r, Region::Prose(p) if p.contains("mc^2"))),
"equation not Prose: {regions:?}"
);
assert!(
regions
.iter()
.filter(|r| matches!(r, Region::Code { .. }))
.count()
>= 1,
"minted/lstlisting/verbatim as CodeBlock: {regions:?}"
);
for r in ®ions {
if let Region::Code { body, .. } = r {
assert!(!matches!(r, Region::Prose(_)), "code body present: {body}");
}
}
assert!(
regions
.iter()
.any(|r| matches!(r, Region::Prose(p) if p.contains("Hello") || p.contains("End"))),
"body prose remains: {regions:?}"
);
}
}