Skip to main content

snapper_fmt/parser/
pandoc.rs

1use std::path::Path;
2use std::process::Command;
3
4use pandoc_ast::{Block, Inline, Pandoc};
5
6use crate::parser::{FormatParser, Region};
7
8/// Parser that uses pandoc as a backend for universal format support.
9/// Requires pandoc binary on PATH.
10pub struct PandocParser {
11    /// Pandoc input format (e.g. "latex", "markdown", "org", "rst", "typst")
12    input_format: String,
13}
14
15impl PandocParser {
16    pub fn new(format: &str) -> Self {
17        Self {
18            input_format: format.to_string(),
19        }
20    }
21
22    /// Detect pandoc input format from file extension.
23    pub fn format_for_path(path: &Path) -> Option<String> {
24        match path.extension().and_then(|e| e.to_str()) {
25            Some("org") => Some("org".to_string()),
26            Some("tex" | "latex" | "ltx") => Some("latex".to_string()),
27            Some("md" | "markdown" | "mkd" | "mdx") => Some("markdown".to_string()),
28            Some("rst" | "rest") => Some("rst".to_string()),
29            Some("typ") => Some("typst".to_string()),
30            Some("adoc" | "asciidoc") => Some("asciidoc".to_string()),
31            Some("html" | "htm") => Some("html".to_string()),
32            Some("docx") => Some("docx".to_string()),
33            Some("txt") => Some("markdown".to_string()),
34            _ => None,
35        }
36    }
37}
38
39/// Check if pandoc is available on PATH.
40pub fn pandoc_available() -> bool {
41    Command::new("pandoc")
42        .arg("--version")
43        .stdout(std::process::Stdio::null())
44        .stderr(std::process::Stdio::null())
45        .status()
46        .is_ok_and(|s| s.success())
47}
48
49impl FormatParser for PandocParser {
50    fn parse(&self, input: &str) -> Vec<Region> {
51        // Run pandoc to get JSON AST
52        let output = Command::new("pandoc")
53            .args(["-f", &self.input_format, "-t", "json"])
54            .stdin(std::process::Stdio::piped())
55            .stdout(std::process::Stdio::piped())
56            .stderr(std::process::Stdio::null())
57            .spawn()
58            .and_then(|mut child| {
59                use std::io::Write;
60                if let Some(ref mut stdin) = child.stdin {
61                    stdin.write_all(input.as_bytes()).ok();
62                }
63                child.wait_with_output()
64            });
65
66        let output = match output {
67            Ok(o) if o.status.success() => o,
68            _ => {
69                // Pandoc failed; treat everything as prose
70                return vec![Region::Prose(input.to_string())];
71            }
72        };
73
74        let json = match String::from_utf8(output.stdout) {
75            Ok(j) => j,
76            Err(_) => return vec![Region::Prose(input.to_string())],
77        };
78
79        // Deserialize pandoc AST
80        let doc: Pandoc = match serde_json::from_str(&json) {
81            Ok(d) => d,
82            Err(_) => return vec![Region::Prose(input.to_string())],
83        };
84
85        // Walk the AST and extract regions
86        let mut regions = Vec::new();
87        for block in &doc.blocks {
88            extract_block(block, &mut regions);
89        }
90
91        // Handle pragma regions -- pandoc strips comments, so pragmas
92        // won't work through pandoc. This is a known limitation.
93
94        regions
95    }
96}
97
98fn extract_block(block: &Block, regions: &mut Vec<Region>) {
99    match block {
100        Block::Para(inlines) | Block::Plain(inlines) => {
101            let text = extract_inlines(inlines);
102            let trimmed = text.trim();
103            if !trimmed.is_empty() {
104                regions.push(Region::Prose(trimmed.to_string()));
105            }
106        }
107        Block::Header(_, _, inlines) => {
108            let text = extract_inlines(inlines);
109            if !text.trim().is_empty() {
110                regions.push(Region::Structure(format!("{}\n", text.trim())));
111            }
112        }
113        Block::CodeBlock(_, code) => {
114            regions.push(Region::Structure(code.clone()));
115        }
116        Block::RawBlock(_, raw) => {
117            regions.push(Region::Structure(raw.clone()));
118        }
119        Block::BlockQuote(blocks) => {
120            for b in blocks {
121                extract_block(b, regions);
122            }
123        }
124        Block::BulletList(items) => {
125            for item in items {
126                for b in item {
127                    extract_block(b, regions);
128                }
129            }
130        }
131        Block::OrderedList(_, items) => {
132            for item in items {
133                for b in item {
134                    extract_block(b, regions);
135                }
136            }
137        }
138        Block::DefinitionList(defs) => {
139            for (term, definitions) in defs {
140                let term_text = extract_inlines(term);
141                if !term_text.trim().is_empty() {
142                    regions.push(Region::Structure(format!("{}\n", term_text.trim())));
143                }
144                for def in definitions {
145                    for b in def {
146                        extract_block(b, regions);
147                    }
148                }
149            }
150        }
151        Block::Table(..) => {
152            // Tables pass through as structure
153            regions.push(Region::Structure("[table]\n".to_string()));
154        }
155        Block::HorizontalRule => {
156            regions.push(Region::Structure("---\n".to_string()));
157        }
158        Block::Div(_, blocks) => {
159            for b in blocks {
160                extract_block(b, regions);
161            }
162        }
163        Block::Figure(_, _, blocks) => {
164            for b in blocks {
165                extract_block(b, regions);
166            }
167        }
168        Block::Null => {}
169        Block::LineBlock(lines) => {
170            // Poetry / preformatted lines -- treat as structure
171            for line in lines {
172                let text = extract_inlines(line);
173                regions.push(Region::Structure(format!("{}\n", text)));
174            }
175        }
176    }
177}
178
179fn extract_inlines(inlines: &[Inline]) -> String {
180    let mut result = String::new();
181    for inline in inlines {
182        match inline {
183            Inline::Str(s) => result.push_str(s),
184            Inline::Space => result.push(' '),
185            Inline::SoftBreak => result.push(' '),
186            Inline::LineBreak => result.push('\n'),
187            Inline::Code(_, code) => {
188                result.push('`');
189                result.push_str(code);
190                result.push('`');
191            }
192            Inline::Math(_, math) => {
193                result.push('$');
194                result.push_str(math);
195                result.push('$');
196            }
197            Inline::Emph(children)
198            | Inline::Strong(children)
199            | Inline::Underline(children)
200            | Inline::Strikeout(children)
201            | Inline::Superscript(children)
202            | Inline::Subscript(children)
203            | Inline::SmallCaps(children)
204            | Inline::Quoted(_, children)
205            | Inline::Span(_, children) => {
206                result.push_str(&extract_inlines(children));
207            }
208            Inline::Cite(_, children) => {
209                result.push_str(&extract_inlines(children));
210            }
211            Inline::Link(_, children, _) => {
212                result.push_str(&extract_inlines(children));
213            }
214            Inline::Image(_, children, _) => {
215                result.push_str(&extract_inlines(children));
216            }
217            Inline::RawInline(_, raw) => {
218                result.push_str(raw);
219            }
220            Inline::Note(_) => {
221                // Footnotes -- skip inline, could recurse
222            }
223        }
224    }
225    result
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn pandoc_availability_check() {
234        // Just verify the function doesn't panic
235        let _ = pandoc_available();
236    }
237
238    #[test]
239    fn pandoc_parser_fallback_on_missing() {
240        // If pandoc isn't available, should return input as prose
241        let parser = PandocParser::new("nonexistent-format");
242        let regions = parser.parse("Hello world.");
243        assert!(!regions.is_empty());
244    }
245
246    #[test]
247    fn pandoc_format_detection() {
248        assert_eq!(
249            PandocParser::format_for_path(Path::new("paper.typ")),
250            Some("typst".to_string())
251        );
252        assert_eq!(
253            PandocParser::format_for_path(Path::new("doc.adoc")),
254            Some("asciidoc".to_string())
255        );
256        assert_eq!(PandocParser::format_for_path(Path::new("file.xyz")), None);
257    }
258
259    #[test]
260    #[ignore] // Requires pandoc on PATH
261    fn pandoc_parses_markdown() {
262        if !pandoc_available() {
263            return;
264        }
265        let parser = PandocParser::new("markdown");
266        let regions = parser
267            .parse("Hello world. Second sentence.\n\n```python\nprint('hi')\n```\n\nMore text.");
268        let prose_count = regions
269            .iter()
270            .filter(|r| matches!(r, Region::Prose(_)))
271            .count();
272        let structure_count = regions
273            .iter()
274            .filter(|r| matches!(r, Region::Structure(_)))
275            .count();
276        assert!(
277            prose_count >= 2,
278            "Expected prose regions, got {prose_count}"
279        );
280        assert!(
281            structure_count >= 1,
282            "Expected structure regions, got {structure_count}"
283        );
284    }
285}