use std::ffi::{CStr, CString};
use std::os::raw::{c_char, c_int};
use std::sync::{Mutex, OnceLock};
use thiserror::Error;
use super::ast::regions_from_pandoc_json;
use crate::parser::Region;
#[derive(Debug, Error)]
pub enum FfiError {
#[error("pandoc FFI library unavailable: {0}")]
LibraryUnavailable(String),
#[error("pandoc FFI parse failed: {0}")]
ParseFailed(String),
#[error("pandoc FFI returned invalid AST: {0}")]
InvalidAst(String),
}
#[cfg(not(feature = "pandoc-colink"))]
type ParseFn = unsafe extern "C" fn(
format: *const c_char,
input: *const c_char,
err_out: *mut *mut c_char,
) -> *mut c_char;
#[cfg(not(feature = "pandoc-colink"))]
type FreeFn = unsafe extern "C" fn(*mut c_char);
#[cfg(not(feature = "pandoc-colink"))]
type ReadyFn = unsafe extern "C" fn();
type HsInitFn = unsafe extern "C" fn(*mut c_int, *mut *mut *mut c_char);
static RTS_GATE: Mutex<()> = Mutex::new(());
static RTS_INIT: OnceLock<()> = OnceLock::new();
unsafe fn call_hs_init(init: HsInitFn) {
unsafe {
init(std::ptr::null_mut(), std::ptr::null_mut());
}
}
#[cfg(feature = "pandoc-colink")]
mod linked {
use super::*;
unsafe extern "C" {
pub fn snapper_pandoc_parse(
format: *const c_char,
input: *const c_char,
err_out: *mut *mut c_char,
) -> *mut c_char;
pub fn snapper_pandoc_free(ptr: *mut c_char);
pub fn snapper_pandoc_hs_ready();
fn hs_init(argc: *mut c_int, argv: *mut *mut *mut c_char);
}
static INIT: OnceLock<Result<(), String>> = OnceLock::new();
pub fn ensure_init() -> Result<(), FfiError> {
let slot = INIT.get_or_init(|| {
RTS_INIT.get_or_init(|| unsafe {
call_hs_init(hs_init);
snapper_pandoc_hs_ready();
});
Ok(())
});
match slot {
Ok(()) => Ok(()),
Err(e) => Err(FfiError::LibraryUnavailable(e.clone())),
}
}
pub fn parse(format: &str, input: &str) -> Result<String, FfiError> {
ensure_init()?;
let fmt =
CString::new(format).map_err(|e| FfiError::ParseFailed(format!("format NUL: {e}")))?;
let inp =
CString::new(input).map_err(|e| FfiError::ParseFailed(format!("input NUL: {e}")))?;
let _rts = RTS_GATE.lock().unwrap_or_else(|p| p.into_inner());
let mut err_ptr: *mut c_char = std::ptr::null_mut();
let json_ptr = unsafe { snapper_pandoc_parse(fmt.as_ptr(), inp.as_ptr(), &mut err_ptr) };
if json_ptr.is_null() {
let msg = if !err_ptr.is_null() {
let s = unsafe { CStr::from_ptr(err_ptr) }
.to_string_lossy()
.into_owned();
unsafe { snapper_pandoc_free(err_ptr) };
s
} else {
"unknown pandoc FFI error".into()
};
return Err(FfiError::ParseFailed(msg));
}
let json = unsafe {
let c = CStr::from_ptr(json_ptr);
match std::str::from_utf8(c.to_bytes()) {
Ok(s) => s.to_owned(),
Err(_) => c.to_string_lossy().into_owned(),
}
};
unsafe { snapper_pandoc_free(json_ptr) };
drop(_rts);
Ok(json)
}
pub fn available() -> bool {
ensure_init().is_ok()
}
}
#[cfg(not(feature = "pandoc-colink"))]
mod dynamic {
use super::*;
use std::path::{Path, PathBuf};
use libloading::{Library, Symbol};
struct FfiApi {
_lib: Library,
parse: ParseFn,
free: FreeFn,
}
static API: OnceLock<Result<FfiApi, String>> = OnceLock::new();
fn candidate_lib_paths() -> Vec<PathBuf> {
if let Ok(p) = std::env::var("SNAPPER_PANDOC_LIB") {
return vec![PathBuf::from(p)];
}
let mut paths = Vec::new();
if let Ok(dir) = std::env::var("SNAPPER_PANDOC_LIB_DIR") {
let dir = PathBuf::from(&dir);
for name in [
"libsnapper_pandoc.so",
"libsnapper_pandoc.dylib",
"snapper_pandoc.dll",
"libsnapper_pandoc.dll",
] {
paths.push(dir.join(name));
}
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
for name in [
"snapper_pandoc.dll",
"libsnapper_pandoc.dll",
"libsnapper_pandoc.so",
"libsnapper_pandoc.dylib",
] {
paths.push(dir.join(name));
}
}
}
for name in [
"libsnapper_pandoc.so",
"libsnapper_pandoc.dylib",
"snapper_pandoc.dll",
"libsnapper_pandoc.dll",
] {
paths.push(PathBuf::from(name));
}
if let Ok(cwd) = std::env::current_dir() {
for name in [
"libsnapper_pandoc.so",
"libsnapper_pandoc.dylib",
"snapper_pandoc.dll",
"libsnapper_pandoc.dll",
] {
let p = cwd.join("native/snapper-pandoc/lib").join(name);
if p.exists() {
paths.push(p);
}
}
let dist = cwd.join("native/snapper-pandoc/dist-newstyle");
if dist.is_dir() {
for name in [
"libsnapper_pandoc.so",
"libsnapper_pandoc.so.0.0.0",
"snapper_pandoc.dll",
"libsnapper_pandoc.dll",
] {
if let Some(found) = find_lib_in_dir(&dist, name) {
paths.push(found);
}
}
}
}
paths
}
fn find_lib_in_dir(root: &Path, name: &str) -> Option<PathBuf> {
fn walk(dir: &Path, name: &str, depth: usize) -> Option<PathBuf> {
if depth > 8 {
return None;
}
let entries = std::fs::read_dir(dir).ok()?;
for ent in entries.flatten() {
let p = ent.path();
if p.is_file() && p.file_name().and_then(|s| s.to_str()) == Some(name) {
return Some(p);
}
if p.is_dir() {
if let Some(found) = walk(&p, name, depth + 1) {
return Some(found);
}
}
}
None
}
walk(root, name, 0)
}
fn open_library(path: &Path) -> Result<FfiApi, String> {
#[cfg(unix)]
let lib = {
use libloading::os::unix::{Library as UnixLibrary, RTLD_GLOBAL, RTLD_NOW};
let flags = RTLD_NOW | RTLD_GLOBAL;
unsafe { UnixLibrary::open(Some(path), flags) }
.map(Library::from)
.map_err(|e| format!("{}: {e}", path.display()))?
};
#[cfg(not(unix))]
let lib = unsafe { Library::new(path) }.map_err(|e| format!("{}: {e}", path.display()))?;
let parse: Symbol<ParseFn> = unsafe { lib.get(b"snapper_pandoc_parse\0") }
.map_err(|e| format!("{}: missing snapper_pandoc_parse: {e}", path.display()))?;
let free: Symbol<FreeFn> = unsafe { lib.get(b"snapper_pandoc_free\0") }
.map_err(|e| format!("{}: missing snapper_pandoc_free: {e}", path.display()))?;
let ready: Option<Symbol<ReadyFn>> = unsafe { lib.get(b"snapper_pandoc_hs_ready\0") }.ok();
let hs_init: Option<Symbol<HsInitFn>> = unsafe { lib.get(b"hs_init\0") }.ok();
RTS_INIT.get_or_init(|| {
if let Some(init) = hs_init.as_ref() {
unsafe {
call_hs_init(**init);
}
}
if let Some(r) = ready.as_ref() {
unsafe {
r();
}
}
});
Ok(FfiApi {
parse: *parse,
free: *free,
_lib: lib,
})
}
fn load_api() -> Result<&'static FfiApi, FfiError> {
let slot = API.get_or_init(|| {
let mut last_err = String::from("no candidate library path tried");
for path in candidate_lib_paths() {
match open_library(&path) {
Ok(api) => return Ok(api),
Err(e) => last_err = e,
}
}
Err(last_err)
});
match slot {
Ok(api) => Ok(api),
Err(msg) => Err(FfiError::LibraryUnavailable(msg.clone())),
}
}
pub fn available() -> bool {
load_api().is_ok()
}
pub fn parse(format: &str, input: &str) -> Result<String, FfiError> {
let api = load_api()?;
let fmt = CString::new(format)
.map_err(|e| FfiError::ParseFailed(format!("format contained NUL: {e}")))?;
let inp = CString::new(input)
.map_err(|e| FfiError::ParseFailed(format!("input contained NUL: {e}")))?;
let _rts = RTS_GATE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let mut err_ptr: *mut c_char = std::ptr::null_mut();
let json_ptr = unsafe { (api.parse)(fmt.as_ptr(), inp.as_ptr(), &mut err_ptr) };
if json_ptr.is_null() {
let msg = if !err_ptr.is_null() {
let s = unsafe { CStr::from_ptr(err_ptr) }
.to_string_lossy()
.into_owned();
unsafe { (api.free)(err_ptr) };
s
} else {
"unknown pandoc FFI error (null result, no message)".to_string()
};
return Err(FfiError::ParseFailed(msg));
}
let json = unsafe {
let c = CStr::from_ptr(json_ptr);
match std::str::from_utf8(c.to_bytes()) {
Ok(s) => s.to_owned(),
Err(_) => c.to_string_lossy().into_owned(),
}
};
unsafe { (api.free)(json_ptr) };
drop(_rts);
Ok(json)
}
}
pub fn ffi_available() -> bool {
#[cfg(feature = "pandoc-colink")]
{
linked::available()
}
#[cfg(not(feature = "pandoc-colink"))]
{
dynamic::available()
}
}
pub fn parse_via_ffi(input: &str, format: &str) -> Result<Vec<Region>, FfiError> {
let (regions, _) = parse_via_ffi_with_json(input, format)?;
Ok(regions)
}
pub fn parse_via_ffi_with_json(
input: &str,
format: &str,
) -> Result<(Vec<Region>, String), FfiError> {
#[cfg(feature = "pandoc-colink")]
let json = linked::parse(format, input)?;
#[cfg(not(feature = "pandoc-colink"))]
let json = dynamic::parse(format, input)?;
let regions = regions_from_pandoc_json(&json).map_err(FfiError::InvalidAst)?;
Ok((regions, json))
}
pub fn ffi_library_unavailable_error(detail: impl Into<String>) -> FfiError {
FfiError::LibraryUnavailable(detail.into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ffi_unavailable_is_explicit_error_not_all_prose() {
let err = ffi_library_unavailable_error("forced unavailable for test");
match &err {
FfiError::LibraryUnavailable(msg) => {
assert!(msg.contains("forced"));
}
other => panic!("expected LibraryUnavailable, got {other}"),
}
let display = err.to_string();
assert!(
display.contains("unavailable"),
"error must be explicit, got: {display}"
);
assert!(!display.contains("Hello world"));
}
#[test]
fn colink_feature_is_documented_in_cfg() {
let _ = ffi_available();
#[cfg(feature = "pandoc-colink")]
{
}
}
#[test]
fn colink_is_static_build_absorb_not_rpath_graph() {
let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let build_rs = std::fs::read_to_string(manifest.join("build.rs")).expect("build.rs");
assert!(
build_rs.contains("libsnapper_pandoc.a"),
"build.rs must look for static archive product"
);
assert!(
build_rs.contains("--gc-sections") || build_rs.contains("gc-sections"),
"build.rs must use section GC on the absorb link"
);
assert!(
!build_rs.contains("ldd_dirs"),
"build.rs must not collect GHC package dirs via ldd for rpath"
);
let script = manifest.join("native/snapper-pandoc/build-static.sh");
assert!(
script.is_file(),
"build-static.sh must exist for archive build"
);
let script_txt = std::fs::read_to_string(&script).expect("build-static.sh");
assert!(
script_txt.contains("-staticlib"),
"build-static.sh must invoke ghc -staticlib"
);
let pack = manifest.join("native/snapper-pandoc/pack-upx.sh");
assert!(
pack.is_file(),
"pack-upx.sh optional pack script must exist"
);
let pack_txt = std::fs::read_to_string(&pack).expect("pack-upx.sh");
assert!(
pack_txt.contains("upx") && pack_txt.contains("-9"),
"pack-upx.sh must invoke upx with compression flags"
);
}
}