use std::path::Path;
use std::process::Command;
use pandoc_ast::{Block, Inline, Pandoc};
use crate::parser::{FormatParser, Region};
pub struct PandocParser {
input_format: String,
}
impl PandocParser {
pub fn new(format: &str) -> Self {
Self {
input_format: format.to_string(),
}
}
pub fn format_for_path(path: &Path) -> Option<String> {
match path.extension().and_then(|e| e.to_str()) {
Some("org") => Some("org".to_string()),
Some("tex" | "latex" | "ltx") => Some("latex".to_string()),
Some("md" | "markdown" | "mkd" | "mdx") => Some("markdown".to_string()),
Some("rst" | "rest") => Some("rst".to_string()),
Some("typ") => Some("typst".to_string()),
Some("adoc" | "asciidoc") => Some("asciidoc".to_string()),
Some("html" | "htm") => Some("html".to_string()),
Some("docx") => Some("docx".to_string()),
Some("txt") => Some("markdown".to_string()),
_ => None,
}
}
}
pub fn pandoc_available() -> bool {
Command::new("pandoc")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
impl FormatParser for PandocParser {
fn parse(&self, input: &str) -> Vec<Region> {
let output = Command::new("pandoc")
.args(["-f", &self.input_format, "-t", "json"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.and_then(|mut child| {
use std::io::Write;
if let Some(ref mut stdin) = child.stdin {
stdin.write_all(input.as_bytes()).ok();
}
child.wait_with_output()
});
let output = match output {
Ok(o) if o.status.success() => o,
_ => {
return vec![Region::Prose(input.to_string())];
}
};
let json = match String::from_utf8(output.stdout) {
Ok(j) => j,
Err(_) => return vec![Region::Prose(input.to_string())],
};
let doc: Pandoc = match serde_json::from_str(&json) {
Ok(d) => d,
Err(_) => return vec![Region::Prose(input.to_string())],
};
let mut regions = Vec::new();
for block in &doc.blocks {
extract_block(block, &mut regions);
}
regions
}
}
fn extract_block(block: &Block, regions: &mut Vec<Region>) {
match block {
Block::Para(inlines) | Block::Plain(inlines) => {
let text = extract_inlines(inlines);
let trimmed = text.trim();
if !trimmed.is_empty() {
regions.push(Region::Prose(trimmed.to_string()));
}
}
Block::Header(_, _, inlines) => {
let text = extract_inlines(inlines);
if !text.trim().is_empty() {
regions.push(Region::Structure(format!("{}\n", text.trim())));
}
}
Block::CodeBlock(_, code) => {
regions.push(Region::Structure(code.clone()));
}
Block::RawBlock(_, raw) => {
regions.push(Region::Structure(raw.clone()));
}
Block::BlockQuote(blocks) => {
for b in blocks {
extract_block(b, regions);
}
}
Block::BulletList(items) => {
for item in items {
for b in item {
extract_block(b, regions);
}
}
}
Block::OrderedList(_, items) => {
for item in items {
for b in item {
extract_block(b, 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(..) => {
regions.push(Region::Structure("[table]\n".to_string()));
}
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!("{}\n", text)));
}
}
}
}
fn extract_inlines(inlines: &[Inline]) -> String {
let mut result = String::new();
for inline in inlines {
match inline {
Inline::Str(s) => result.push_str(s),
Inline::Space => result.push(' '),
Inline::SoftBreak => result.push(' '),
Inline::LineBreak => result.push('\n'),
Inline::Code(_, code) => {
result.push('`');
result.push_str(code);
result.push('`');
}
Inline::Math(_, math) => {
result.push('$');
result.push_str(math);
result.push('$');
}
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) => {
result.push_str(&extract_inlines(children));
}
Inline::Cite(_, children) => {
result.push_str(&extract_inlines(children));
}
Inline::Link(_, children, _) => {
result.push_str(&extract_inlines(children));
}
Inline::Image(_, children, _) => {
result.push_str(&extract_inlines(children));
}
Inline::RawInline(_, raw) => {
result.push_str(raw);
}
Inline::Note(_) => {
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pandoc_availability_check() {
let _ = pandoc_available();
}
#[test]
fn pandoc_parser_fallback_on_missing() {
let parser = PandocParser::new("nonexistent-format");
let regions = parser.parse("Hello world.");
assert!(!regions.is_empty());
}
#[test]
fn pandoc_format_detection() {
assert_eq!(
PandocParser::format_for_path(Path::new("paper.typ")),
Some("typst".to_string())
);
assert_eq!(
PandocParser::format_for_path(Path::new("doc.adoc")),
Some("asciidoc".to_string())
);
assert_eq!(PandocParser::format_for_path(Path::new("file.xyz")), None);
}
#[test]
#[ignore] fn pandoc_parses_markdown() {
if !pandoc_available() {
return;
}
let parser = PandocParser::new("markdown");
let regions = parser
.parse("Hello world. Second sentence.\n\n```python\nprint('hi')\n```\n\nMore text.");
let prose_count = regions
.iter()
.filter(|r| matches!(r, Region::Prose(_)))
.count();
let structure_count = regions
.iter()
.filter(|r| matches!(r, Region::Structure(_)))
.count();
assert!(
prose_count >= 2,
"Expected prose regions, got {prose_count}"
);
assert!(
structure_count >= 1,
"Expected structure regions, got {structure_count}"
);
}
}