snapper_fmt/parser/pandoc/
ffi.rs1use std::ffi::{CStr, CString};
14use std::os::raw::{c_char, c_int};
15use std::sync::{Mutex, OnceLock};
16
17use thiserror::Error;
18
19use super::ast::regions_from_pandoc_json;
20use crate::parser::Region;
21
22#[derive(Debug, Error)]
24pub enum FfiError {
25 #[error("pandoc FFI library unavailable: {0}")]
26 LibraryUnavailable(String),
27 #[error("pandoc FFI parse failed: {0}")]
28 ParseFailed(String),
29 #[error("pandoc FFI returned invalid AST: {0}")]
30 InvalidAst(String),
31}
32
33#[cfg(not(feature = "pandoc-colink"))]
34type ParseFn = unsafe extern "C" fn(
35 format: *const c_char,
36 input: *const c_char,
37 err_out: *mut *mut c_char,
38) -> *mut c_char;
39#[cfg(not(feature = "pandoc-colink"))]
40type FreeFn = unsafe extern "C" fn(*mut c_char);
41#[cfg(not(feature = "pandoc-colink"))]
42type ReadyFn = unsafe extern "C" fn();
43type HsInitFn = unsafe extern "C" fn(*mut c_int, *mut *mut *mut c_char);
44
45static RTS_GATE: Mutex<()> = Mutex::new(());
47static RTS_INIT: OnceLock<()> = OnceLock::new();
48
49unsafe fn call_hs_init(init: HsInitFn) {
57 unsafe {
59 init(std::ptr::null_mut(), std::ptr::null_mut());
60 }
61}
62
63#[cfg(feature = "pandoc-colink")]
67mod linked {
68 use super::*;
69
70 unsafe extern "C" {
73 pub fn snapper_pandoc_parse(
74 format: *const c_char,
75 input: *const c_char,
76 err_out: *mut *mut c_char,
77 ) -> *mut c_char;
78 pub fn snapper_pandoc_free(ptr: *mut c_char);
79 pub fn snapper_pandoc_hs_ready();
80 fn hs_init(argc: *mut c_int, argv: *mut *mut *mut c_char);
81 }
82
83 static INIT: OnceLock<Result<(), String>> = OnceLock::new();
84
85 pub fn ensure_init() -> Result<(), FfiError> {
86 let slot = INIT.get_or_init(|| {
87 RTS_INIT.get_or_init(|| unsafe {
88 call_hs_init(hs_init);
89 snapper_pandoc_hs_ready();
90 });
91 Ok(())
92 });
93 match slot {
94 Ok(()) => Ok(()),
95 Err(e) => Err(FfiError::LibraryUnavailable(e.clone())),
96 }
97 }
98
99 pub fn parse(format: &str, input: &str) -> Result<String, FfiError> {
100 ensure_init()?;
101 let fmt =
102 CString::new(format).map_err(|e| FfiError::ParseFailed(format!("format NUL: {e}")))?;
103 let inp =
104 CString::new(input).map_err(|e| FfiError::ParseFailed(format!("input NUL: {e}")))?;
105 let _rts = RTS_GATE.lock().unwrap_or_else(|p| p.into_inner());
106 let mut err_ptr: *mut c_char = std::ptr::null_mut();
107 let json_ptr = unsafe { snapper_pandoc_parse(fmt.as_ptr(), inp.as_ptr(), &mut err_ptr) };
108 if json_ptr.is_null() {
109 let msg = if !err_ptr.is_null() {
110 let s = unsafe { CStr::from_ptr(err_ptr) }
111 .to_string_lossy()
112 .into_owned();
113 unsafe { snapper_pandoc_free(err_ptr) };
114 s
115 } else {
116 "unknown pandoc FFI error".into()
117 };
118 return Err(FfiError::ParseFailed(msg));
119 }
120 let json = unsafe {
121 let c = CStr::from_ptr(json_ptr);
122 match std::str::from_utf8(c.to_bytes()) {
123 Ok(s) => s.to_owned(),
124 Err(_) => c.to_string_lossy().into_owned(),
125 }
126 };
127 unsafe { snapper_pandoc_free(json_ptr) };
128 drop(_rts);
129 Ok(json)
130 }
131
132 pub fn available() -> bool {
133 ensure_init().is_ok()
134 }
135}
136
137#[cfg(not(feature = "pandoc-colink"))]
141mod dynamic {
142 use super::*;
143 use std::path::{Path, PathBuf};
144
145 use libloading::{Library, Symbol};
146
147 struct FfiApi {
148 _lib: Library,
149 parse: ParseFn,
150 free: FreeFn,
151 }
152
153 static API: OnceLock<Result<FfiApi, String>> = OnceLock::new();
154
155 fn candidate_lib_paths() -> Vec<PathBuf> {
156 if let Ok(p) = std::env::var("SNAPPER_PANDOC_LIB") {
157 return vec![PathBuf::from(p)];
158 }
159 let mut paths = Vec::new();
160 if let Ok(dir) = std::env::var("SNAPPER_PANDOC_LIB_DIR") {
161 let dir = PathBuf::from(&dir);
162 for name in [
163 "libsnapper_pandoc.so",
164 "libsnapper_pandoc.dylib",
165 "snapper_pandoc.dll",
166 "libsnapper_pandoc.dll",
167 ] {
168 paths.push(dir.join(name));
169 }
170 }
171 if let Ok(exe) = std::env::current_exe() {
173 if let Some(dir) = exe.parent() {
174 for name in [
175 "snapper_pandoc.dll",
176 "libsnapper_pandoc.dll",
177 "libsnapper_pandoc.so",
178 "libsnapper_pandoc.dylib",
179 ] {
180 paths.push(dir.join(name));
181 }
182 }
183 }
184 for name in [
185 "libsnapper_pandoc.so",
186 "libsnapper_pandoc.dylib",
187 "snapper_pandoc.dll",
188 "libsnapper_pandoc.dll",
189 ] {
190 paths.push(PathBuf::from(name));
191 }
192 if let Ok(cwd) = std::env::current_dir() {
193 for name in [
194 "libsnapper_pandoc.so",
195 "libsnapper_pandoc.dylib",
196 "snapper_pandoc.dll",
197 "libsnapper_pandoc.dll",
198 ] {
199 let p = cwd.join("native/snapper-pandoc/lib").join(name);
200 if p.exists() {
201 paths.push(p);
202 }
203 }
204 let dist = cwd.join("native/snapper-pandoc/dist-newstyle");
205 if dist.is_dir() {
206 for name in [
207 "libsnapper_pandoc.so",
208 "libsnapper_pandoc.so.0.0.0",
209 "snapper_pandoc.dll",
210 "libsnapper_pandoc.dll",
211 ] {
212 if let Some(found) = find_lib_in_dir(&dist, name) {
213 paths.push(found);
214 }
215 }
216 }
217 }
218 paths
219 }
220
221 fn find_lib_in_dir(root: &Path, name: &str) -> Option<PathBuf> {
222 fn walk(dir: &Path, name: &str, depth: usize) -> Option<PathBuf> {
223 if depth > 8 {
224 return None;
225 }
226 let entries = std::fs::read_dir(dir).ok()?;
227 for ent in entries.flatten() {
228 let p = ent.path();
229 if p.is_file() && p.file_name().and_then(|s| s.to_str()) == Some(name) {
230 return Some(p);
231 }
232 if p.is_dir() {
233 if let Some(found) = walk(&p, name, depth + 1) {
234 return Some(found);
235 }
236 }
237 }
238 None
239 }
240 walk(root, name, 0)
241 }
242
243 fn open_library(path: &Path) -> Result<FfiApi, String> {
244 #[cfg(unix)]
245 let lib = {
246 use libloading::os::unix::{Library as UnixLibrary, RTLD_GLOBAL, RTLD_NOW};
247 let flags = RTLD_NOW | RTLD_GLOBAL;
248 unsafe { UnixLibrary::open(Some(path), flags) }
249 .map(Library::from)
250 .map_err(|e| format!("{}: {e}", path.display()))?
251 };
252 #[cfg(not(unix))]
253 let lib = unsafe { Library::new(path) }.map_err(|e| format!("{}: {e}", path.display()))?;
254
255 let parse: Symbol<ParseFn> = unsafe { lib.get(b"snapper_pandoc_parse\0") }
256 .map_err(|e| format!("{}: missing snapper_pandoc_parse: {e}", path.display()))?;
257 let free: Symbol<FreeFn> = unsafe { lib.get(b"snapper_pandoc_free\0") }
258 .map_err(|e| format!("{}: missing snapper_pandoc_free: {e}", path.display()))?;
259 let ready: Option<Symbol<ReadyFn>> = unsafe { lib.get(b"snapper_pandoc_hs_ready\0") }.ok();
260 let hs_init: Option<Symbol<HsInitFn>> = unsafe { lib.get(b"hs_init\0") }.ok();
261
262 RTS_INIT.get_or_init(|| {
263 if let Some(init) = hs_init.as_ref() {
264 unsafe {
265 call_hs_init(**init);
266 }
267 }
268 if let Some(r) = ready.as_ref() {
269 unsafe {
270 r();
271 }
272 }
273 });
274
275 Ok(FfiApi {
276 parse: *parse,
277 free: *free,
278 _lib: lib,
279 })
280 }
281
282 fn load_api() -> Result<&'static FfiApi, FfiError> {
283 let slot = API.get_or_init(|| {
284 let mut last_err = String::from("no candidate library path tried");
285 for path in candidate_lib_paths() {
286 match open_library(&path) {
287 Ok(api) => return Ok(api),
288 Err(e) => last_err = e,
289 }
290 }
291 Err(last_err)
292 });
293 match slot {
294 Ok(api) => Ok(api),
295 Err(msg) => Err(FfiError::LibraryUnavailable(msg.clone())),
296 }
297 }
298
299 pub fn available() -> bool {
300 load_api().is_ok()
301 }
302
303 pub fn parse(format: &str, input: &str) -> Result<String, FfiError> {
304 let api = load_api()?;
305 let fmt = CString::new(format)
306 .map_err(|e| FfiError::ParseFailed(format!("format contained NUL: {e}")))?;
307 let inp = CString::new(input)
308 .map_err(|e| FfiError::ParseFailed(format!("input contained NUL: {e}")))?;
309 let _rts = RTS_GATE
310 .lock()
311 .unwrap_or_else(|poisoned| poisoned.into_inner());
312 let mut err_ptr: *mut c_char = std::ptr::null_mut();
313 let json_ptr = unsafe { (api.parse)(fmt.as_ptr(), inp.as_ptr(), &mut err_ptr) };
314 if json_ptr.is_null() {
315 let msg = if !err_ptr.is_null() {
316 let s = unsafe { CStr::from_ptr(err_ptr) }
317 .to_string_lossy()
318 .into_owned();
319 unsafe { (api.free)(err_ptr) };
320 s
321 } else {
322 "unknown pandoc FFI error (null result, no message)".to_string()
323 };
324 return Err(FfiError::ParseFailed(msg));
325 }
326 let json = unsafe {
327 let c = CStr::from_ptr(json_ptr);
328 match std::str::from_utf8(c.to_bytes()) {
329 Ok(s) => s.to_owned(),
330 Err(_) => c.to_string_lossy().into_owned(),
331 }
332 };
333 unsafe { (api.free)(json_ptr) };
334 drop(_rts);
335 Ok(json)
336 }
337}
338
339pub fn ffi_available() -> bool {
345 #[cfg(feature = "pandoc-colink")]
346 {
347 linked::available()
348 }
349 #[cfg(not(feature = "pandoc-colink"))]
350 {
351 dynamic::available()
352 }
353}
354
355pub fn parse_via_ffi(input: &str, format: &str) -> Result<Vec<Region>, FfiError> {
357 let (regions, _) = parse_via_ffi_with_json(input, format)?;
358 Ok(regions)
359}
360
361pub fn parse_via_ffi_with_json(
363 input: &str,
364 format: &str,
365) -> Result<(Vec<Region>, String), FfiError> {
366 #[cfg(feature = "pandoc-colink")]
367 let json = linked::parse(format, input)?;
368 #[cfg(not(feature = "pandoc-colink"))]
369 let json = dynamic::parse(format, input)?;
370
371 let regions = regions_from_pandoc_json(&json).map_err(FfiError::InvalidAst)?;
372 Ok((regions, json))
373}
374
375pub fn ffi_library_unavailable_error(detail: impl Into<String>) -> FfiError {
377 FfiError::LibraryUnavailable(detail.into())
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 #[test]
385 fn ffi_unavailable_is_explicit_error_not_all_prose() {
386 let err = ffi_library_unavailable_error("forced unavailable for test");
387 match &err {
388 FfiError::LibraryUnavailable(msg) => {
389 assert!(msg.contains("forced"));
390 }
391 other => panic!("expected LibraryUnavailable, got {other}"),
392 }
393 let display = err.to_string();
394 assert!(
395 display.contains("unavailable"),
396 "error must be explicit, got: {display}"
397 );
398 assert!(!display.contains("Hello world"));
399 }
400
401 #[test]
402 fn colink_feature_is_documented_in_cfg() {
403 let _ = ffi_available();
405 #[cfg(feature = "pandoc-colink")]
406 {
407 }
410 }
411
412 #[test]
414 fn colink_is_static_build_absorb_not_rpath_graph() {
415 let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
416 let build_rs = std::fs::read_to_string(manifest.join("build.rs")).expect("build.rs");
417 assert!(
418 build_rs.contains("libsnapper_pandoc.a"),
419 "build.rs must look for static archive product"
420 );
421 assert!(
422 build_rs.contains("--gc-sections") || build_rs.contains("gc-sections"),
423 "build.rs must use section GC on the absorb link"
424 );
425 assert!(
426 !build_rs.contains("ldd_dirs"),
427 "build.rs must not collect GHC package dirs via ldd for rpath"
428 );
429 let script = manifest.join("native/snapper-pandoc/build-static.sh");
430 assert!(
431 script.is_file(),
432 "build-static.sh must exist for archive build"
433 );
434 let script_txt = std::fs::read_to_string(&script).expect("build-static.sh");
435 assert!(
436 script_txt.contains("-staticlib"),
437 "build-static.sh must invoke ghc -staticlib"
438 );
439 let pack = manifest.join("native/snapper-pandoc/pack-upx.sh");
440 assert!(
441 pack.is_file(),
442 "pack-upx.sh optional pack script must exist"
443 );
444 let pack_txt = std::fs::read_to_string(&pack).expect("pack-upx.sh");
445 assert!(
446 pack_txt.contains("upx") && pack_txt.contains("-9"),
447 "pack-upx.sh must invoke upx with compression flags"
448 );
449 }
450}