snapper_fmt/parser/
pandoc.rs1use std::path::Path;
2use std::process::Command;
3
4use pandoc_ast::{Block, Inline, Pandoc};
5
6use crate::parser::{FormatParser, Region};
7
8pub struct PandocParser {
11 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 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
39pub 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 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 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 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 let mut regions = Vec::new();
87 for block in &doc.blocks {
88 extract_block(block, &mut regions);
89 }
90
91 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 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 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 }
223 }
224 }
225 result
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn pandoc_availability_check() {
234 let _ = pandoc_available();
236 }
237
238 #[test]
239 fn pandoc_parser_fallback_on_missing() {
240 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] 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}