Skip to main content

snapper_fmt/parser/pandoc/
mod.rs

1//! Pandoc parses first; snapper reflows second.
2//!
3//! For any format pandoc can read:
4//! 1. **Parse** the source with pandoc → document AST (JSON via CLI, or
5//!    in-process FFI).
6//! 2. **Apply** snapper only to prose-bearing nodes (`Para` / `Plain`); leave
7//!    `Header`, `CodeBlock`, `Table`, etc. alone because the AST says they are
8//!    not prose.
9//!
10//! That is the opposite of the native path (guess structure from source lines,
11//! then reflow). Here pandoc owns structure; snapper owns sentence line breaks
12//! on the prose leaves.
13//!
14//! Backends that produce the same AST for [`ast::regions_from_pandoc`]:
15//! - **CLI** ([`PandocBackend::Cli`]): `pandoc -t json` (full installed readers).
16//! - **FFI** ([`PandocBackend::Ffi`]): `libsnapper_pandoc` (linked library readers).
17
18pub 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/// How to obtain the pandoc AST.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum PandocBackend {
37    /// Prefer in-process FFI when `libsnapper_pandoc` loads; else CLI.
38    /// Successor default: amortizes RTS and avoids process-per-file spawn.
39    #[default]
40    Auto,
41    /// In-process Haskell FFI (`libsnapper_pandoc`). Explicit error if unavailable.
42    Ffi,
43    /// Subprocess `pandoc -t json`. Explicit error if pandoc fails.
44    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    /// Resolve Auto → Ffi if the library loads, else Cli.
72    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/// Errors from either pandoc backend when explicitly selected.
87#[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
97/// Parse input with the selected backend and classify via the pandoc AST.
98///
99/// Uses a content-addressed AST JSON cache (memory + disk) so repeated formats
100/// of the same source skip pandoc entirely after the first successful parse.
101pub 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
126/// Parser that uses pandoc for universal format support.
127pub struct PandocParser {
128    /// Pandoc input format (e.g. "latex", "markdown", "org", "rst", "typst")
129    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    /// Fallible parse used by the library entry path (preferred).
153    pub fn try_parse(&self, input: &str) -> Result<Vec<Region>, PandocError> {
154        parse_with_backend(input, &self.input_format, self.backend)
155    }
156
157    /// Detect pandoc input format from file extension.
158    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    /// Prefer [`PandocParser::try_parse`] / `format_text` (they surface errors).
176    /// On failure this returns **empty** regions — never all-prose fallback.
177    /// (`format_text` does not use this trait method for the pandoc path.)
178    /// Pandoc rebuilds regions from an AST, so origins are unset and reflow
179    /// falls back to concatenating region strings.
180    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        // When the library is missing, FFI mode must error.
232        if ffi_available() {
233            // Environment has the lib; still verify parse returns regions of mixed kinds
234            // if we can (optional live check).
235            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}