pub mod ast;
pub mod cache;
pub mod cli;
pub mod ffi;
use std::path::Path;
use std::str::FromStr;
use thiserror::Error;
use crate::parser::{FormatParser, Region};
pub use ast::{regions_from_pandoc, regions_from_pandoc_json};
pub use cli::pandoc_cli_available as pandoc_available;
pub use ffi::ffi_available;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PandocBackend {
#[default]
Auto,
Ffi,
Cli,
}
impl FromStr for PandocBackend {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"auto" | "default" => Ok(Self::Auto),
"ffi" | "lib" | "inprocess" | "in-process" => Ok(Self::Ffi),
"cli" | "subprocess" | "command" => Ok(Self::Cli),
other => Err(format!(
"unknown pandoc backend '{other}' (expected 'auto', 'ffi', or 'cli')"
)),
}
}
}
impl PandocBackend {
pub fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Ffi => "ffi",
Self::Cli => "cli",
}
}
pub fn resolve(self) -> Self {
match self {
Self::Auto => {
if ffi_available() {
Self::Ffi
} else {
Self::Cli
}
}
other => other,
}
}
}
#[derive(Debug, Error)]
pub enum PandocError {
#[error(transparent)]
Ffi(#[from] ffi::FfiError),
#[error(transparent)]
Cli(#[from] cli::CliError),
#[error("pandoc AST cache/classify: {0}")]
Ast(String),
}
pub fn parse_with_backend(
input: &str,
format: &str,
backend: PandocBackend,
) -> Result<Vec<Region>, PandocError> {
if let Some(json) = cache::get_json(format, input) {
return regions_from_pandoc_json(json.as_ref()).map_err(PandocError::Ast);
}
let (regions, json_opt) = match backend.resolve() {
PandocBackend::Auto => unreachable!("resolve collapses Auto"),
PandocBackend::Ffi => {
let (regs, json) = ffi::parse_via_ffi_with_json(input, format)?;
(regs, Some(json))
}
PandocBackend::Cli => {
let (regs, json) = cli::parse_via_cli_with_json(input, format)?;
(regs, Some(json))
}
};
if let Some(json) = json_opt {
cache::put_json(format, input, &json);
}
Ok(regions)
}
pub struct PandocParser {
input_format: String,
backend: PandocBackend,
}
impl PandocParser {
pub fn new(format: &str) -> Self {
Self {
input_format: format.to_string(),
backend: PandocBackend::default(),
}
}
pub fn with_backend(format: &str, backend: PandocBackend) -> Self {
Self {
input_format: format.to_string(),
backend,
}
}
pub fn backend(&self) -> PandocBackend {
self.backend
}
pub fn try_parse(&self, input: &str) -> Result<Vec<Region>, PandocError> {
parse_with_backend(input, &self.input_format, self.backend)
}
pub fn format_for_path(path: &Path) -> Option<String> {
match path.extension().and_then(|e| e.to_str()) {
Some("org") => Some("org".to_string()),
Some("tex" | "latex" | "ltx") => Some("latex".to_string()),
Some("md" | "markdown" | "mkd" | "mdx") => Some("markdown".to_string()),
Some("rst" | "rest") => Some("rst".to_string()),
Some("typ") => Some("typst".to_string()),
Some("adoc" | "asciidoc") => Some("asciidoc".to_string()),
Some("html" | "htm") => Some("html".to_string()),
Some("docx") => Some("docx".to_string()),
Some("txt") => Some("markdown".to_string()),
_ => None,
}
}
}
impl FormatParser for PandocParser {
fn parse_full(&self, input: &str) -> Vec<crate::parser::SpannedRegion> {
self.try_parse(input)
.unwrap_or_default()
.into_iter()
.map(crate::parser::SpannedRegion::unspanned)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backend_from_str() {
assert_eq!(
"auto".parse::<PandocBackend>().unwrap(),
PandocBackend::Auto
);
assert_eq!("ffi".parse::<PandocBackend>().unwrap(), PandocBackend::Ffi);
assert_eq!("cli".parse::<PandocBackend>().unwrap(), PandocBackend::Cli);
assert_eq!(PandocBackend::default(), PandocBackend::Auto);
assert!("bogus".parse::<PandocBackend>().is_err());
}
#[test]
fn backend_auto_resolves_to_ffi_or_cli() {
let r = PandocBackend::Auto.resolve();
assert!(matches!(r, PandocBackend::Ffi | PandocBackend::Cli));
if ffi_available() {
assert_eq!(r, PandocBackend::Ffi);
} else {
assert_eq!(r, PandocBackend::Cli);
}
}
#[test]
fn pandoc_format_detection() {
assert_eq!(
PandocParser::format_for_path(Path::new("paper.typ")),
Some("typst".to_string())
);
assert_eq!(
PandocParser::format_for_path(Path::new("doc.adoc")),
Some("asciidoc".to_string())
);
assert_eq!(PandocParser::format_for_path(Path::new("file.xyz")), None);
}
#[test]
fn try_parse_ffi_without_lib_is_err_not_all_prose() {
if ffi_available() {
return;
}
let parser = PandocParser::with_backend("markdown", PandocBackend::Ffi);
let err = parser.try_parse("Hello world.\n\n# Title\n").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("unavailable") || msg.contains("FFI") || msg.contains("library"),
"expected explicit FFI unavailability, got: {msg}"
);
}
}