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    fn parse(&self, input: &str) -> Vec<Region> {
179        self.try_parse(input).unwrap_or_default()
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn backend_from_str() {
189        assert_eq!(
190            "auto".parse::<PandocBackend>().unwrap(),
191            PandocBackend::Auto
192        );
193        assert_eq!("ffi".parse::<PandocBackend>().unwrap(), PandocBackend::Ffi);
194        assert_eq!("cli".parse::<PandocBackend>().unwrap(), PandocBackend::Cli);
195        assert_eq!(PandocBackend::default(), PandocBackend::Auto);
196        assert!("bogus".parse::<PandocBackend>().is_err());
197    }
198
199    #[test]
200    fn backend_auto_resolves_to_ffi_or_cli() {
201        let r = PandocBackend::Auto.resolve();
202        assert!(matches!(r, PandocBackend::Ffi | PandocBackend::Cli));
203        if ffi_available() {
204            assert_eq!(r, PandocBackend::Ffi);
205        } else {
206            assert_eq!(r, PandocBackend::Cli);
207        }
208    }
209
210    #[test]
211    fn pandoc_format_detection() {
212        assert_eq!(
213            PandocParser::format_for_path(Path::new("paper.typ")),
214            Some("typst".to_string())
215        );
216        assert_eq!(
217            PandocParser::format_for_path(Path::new("doc.adoc")),
218            Some("asciidoc".to_string())
219        );
220        assert_eq!(PandocParser::format_for_path(Path::new("file.xyz")), None);
221    }
222
223    #[test]
224    fn try_parse_ffi_without_lib_is_err_not_all_prose() {
225        // When the library is missing, FFI mode must error.
226        if ffi_available() {
227            // Environment has the lib; still verify parse returns regions of mixed kinds
228            // if we can (optional live check).
229            return;
230        }
231        let parser = PandocParser::with_backend("markdown", PandocBackend::Ffi);
232        let err = parser.try_parse("Hello world.\n\n# Title\n").unwrap_err();
233        let msg = err.to_string();
234        assert!(
235            msg.contains("unavailable") || msg.contains("FFI") || msg.contains("library"),
236            "expected explicit FFI unavailability, got: {msg}"
237        );
238    }
239}