snapper_fmt/parser/pandoc/
mod.rs1pub mod ast;
19pub mod cache;
20pub mod cli;
21pub mod ffi;
22
23use std::path::Path;
24use std::str::FromStr;
25
26use thiserror::Error;
27
28use crate::parser::{FormatParser, Region};
29
30pub use ast::{regions_from_pandoc, regions_from_pandoc_json};
31pub use cli::pandoc_cli_available as pandoc_available;
32pub use ffi::ffi_available;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum PandocBackend {
37 #[default]
40 Auto,
41 Ffi,
43 Cli,
45}
46
47impl FromStr for PandocBackend {
48 type Err = String;
49
50 fn from_str(s: &str) -> Result<Self, Self::Err> {
51 match s.to_ascii_lowercase().as_str() {
52 "auto" | "default" => Ok(Self::Auto),
53 "ffi" | "lib" | "inprocess" | "in-process" => Ok(Self::Ffi),
54 "cli" | "subprocess" | "command" => Ok(Self::Cli),
55 other => Err(format!(
56 "unknown pandoc backend '{other}' (expected 'auto', 'ffi', or 'cli')"
57 )),
58 }
59 }
60}
61
62impl PandocBackend {
63 pub fn as_str(self) -> &'static str {
64 match self {
65 Self::Auto => "auto",
66 Self::Ffi => "ffi",
67 Self::Cli => "cli",
68 }
69 }
70
71 pub fn resolve(self) -> Self {
73 match self {
74 Self::Auto => {
75 if ffi_available() {
76 Self::Ffi
77 } else {
78 Self::Cli
79 }
80 }
81 other => other,
82 }
83 }
84}
85
86#[derive(Debug, Error)]
88pub enum PandocError {
89 #[error(transparent)]
90 Ffi(#[from] ffi::FfiError),
91 #[error(transparent)]
92 Cli(#[from] cli::CliError),
93 #[error("pandoc AST cache/classify: {0}")]
94 Ast(String),
95}
96
97pub fn parse_with_backend(
102 input: &str,
103 format: &str,
104 backend: PandocBackend,
105) -> Result<Vec<Region>, PandocError> {
106 if let Some(json) = cache::get_json(format, input) {
107 return regions_from_pandoc_json(json.as_ref()).map_err(PandocError::Ast);
108 }
109 let (regions, json_opt) = match backend.resolve() {
110 PandocBackend::Auto => unreachable!("resolve collapses Auto"),
111 PandocBackend::Ffi => {
112 let (regs, json) = ffi::parse_via_ffi_with_json(input, format)?;
113 (regs, Some(json))
114 }
115 PandocBackend::Cli => {
116 let (regs, json) = cli::parse_via_cli_with_json(input, format)?;
117 (regs, Some(json))
118 }
119 };
120 if let Some(json) = json_opt {
121 cache::put_json(format, input, &json);
122 }
123 Ok(regions)
124}
125
126pub struct PandocParser {
128 input_format: String,
130 backend: PandocBackend,
131}
132
133impl PandocParser {
134 pub fn new(format: &str) -> Self {
135 Self {
136 input_format: format.to_string(),
137 backend: PandocBackend::default(),
138 }
139 }
140
141 pub fn with_backend(format: &str, backend: PandocBackend) -> Self {
142 Self {
143 input_format: format.to_string(),
144 backend,
145 }
146 }
147
148 pub fn backend(&self) -> PandocBackend {
149 self.backend
150 }
151
152 pub fn try_parse(&self, input: &str) -> Result<Vec<Region>, PandocError> {
154 parse_with_backend(input, &self.input_format, self.backend)
155 }
156
157 pub fn format_for_path(path: &Path) -> Option<String> {
159 match path.extension().and_then(|e| e.to_str()) {
160 Some("org") => Some("org".to_string()),
161 Some("tex" | "latex" | "ltx") => Some("latex".to_string()),
162 Some("md" | "markdown" | "mkd" | "mdx") => Some("markdown".to_string()),
163 Some("rst" | "rest") => Some("rst".to_string()),
164 Some("typ") => Some("typst".to_string()),
165 Some("adoc" | "asciidoc") => Some("asciidoc".to_string()),
166 Some("html" | "htm") => Some("html".to_string()),
167 Some("docx") => Some("docx".to_string()),
168 Some("txt") => Some("markdown".to_string()),
169 _ => None,
170 }
171 }
172}
173
174impl FormatParser for PandocParser {
175 fn parse_full(&self, input: &str) -> Vec<crate::parser::SpannedRegion> {
181 self.try_parse(input)
182 .unwrap_or_default()
183 .into_iter()
184 .map(crate::parser::SpannedRegion::unspanned)
185 .collect()
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn backend_from_str() {
195 assert_eq!(
196 "auto".parse::<PandocBackend>().unwrap(),
197 PandocBackend::Auto
198 );
199 assert_eq!("ffi".parse::<PandocBackend>().unwrap(), PandocBackend::Ffi);
200 assert_eq!("cli".parse::<PandocBackend>().unwrap(), PandocBackend::Cli);
201 assert_eq!(PandocBackend::default(), PandocBackend::Auto);
202 assert!("bogus".parse::<PandocBackend>().is_err());
203 }
204
205 #[test]
206 fn backend_auto_resolves_to_ffi_or_cli() {
207 let r = PandocBackend::Auto.resolve();
208 assert!(matches!(r, PandocBackend::Ffi | PandocBackend::Cli));
209 if ffi_available() {
210 assert_eq!(r, PandocBackend::Ffi);
211 } else {
212 assert_eq!(r, PandocBackend::Cli);
213 }
214 }
215
216 #[test]
217 fn pandoc_format_detection() {
218 assert_eq!(
219 PandocParser::format_for_path(Path::new("paper.typ")),
220 Some("typst".to_string())
221 );
222 assert_eq!(
223 PandocParser::format_for_path(Path::new("doc.adoc")),
224 Some("asciidoc".to_string())
225 );
226 assert_eq!(PandocParser::format_for_path(Path::new("file.xyz")), None);
227 }
228
229 #[test]
230 fn try_parse_ffi_without_lib_is_err_not_all_prose() {
231 if ffi_available() {
233 return;
236 }
237 let parser = PandocParser::with_backend("markdown", PandocBackend::Ffi);
238 let err = parser.try_parse("Hello world.\n\n# Title\n").unwrap_err();
239 let msg = err.to_string();
240 assert!(
241 msg.contains("unavailable") || msg.contains("FFI") || msg.contains("library"),
242 "expected explicit FFI unavailability, got: {msg}"
243 );
244 }
245}