Skip to main content

snapper_fmt/parser/pandoc/
ffi.rs

1//! In-process pandoc via Haskell foreign-library (`libsnapper_pandoc`).
2//!
3//! Two link modes:
4//! - **Default (`pandoc` feature):** `dlopen` / `LoadLibrary` via `libloading`
5//!   (`SNAPPER_PANDOC_LIB`, next-to-exe, or search paths). This is the
6//!   **Windows native** path (`snapper_pandoc.dll` from cabal `foreign-library`).
7//! - **`pandoc-colink` (Unix):** link-time absorb of `libsnapper_pandoc.a`
8//!   (`build.rs` + `build-static.sh`); symbols live in the process image.
9//!   PE static absorb is unsupported in CI.
10//!
11//! Failures are explicit [`FfiError`] values — never silent all-prose.
12
13use 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/// Errors from the in-process pandoc FFI path.
23#[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
45/// Non-threaded GHC RTS is not re-entrant from multiple OS threads.
46static RTS_GATE: Mutex<()> = Mutex::new(());
47static RTS_INIT: OnceLock<()> = OnceLock::new();
48
49/// Call `hs_init`. Prefer `NULL, NULL` (allowed by GHC) — avoids argv lifetime
50/// and mutability issues. Windows GHA dual-run previously hit
51/// `STATUS_HEAP_CORRUPTION` (0xC0000374) with a fragile one-slot argv vector
52/// taken via const `as_ptr`; C-side chk_parse with a proper argv worked.
53///
54/// Fallback: process-lifetime null-terminated argv (`[arg0, NULL]`) if null
55/// init is ever rejected by a future RTS (not expected).
56unsafe fn call_hs_init(init: HsInitFn) {
57    // Primary: null argc/argv (GHC embedding contract).
58    unsafe {
59        init(std::ptr::null_mut(), std::ptr::null_mut());
60    }
61}
62
63// ---------------------------------------------------------------------------
64// Co-linked symbols (feature pandoc-colink)
65// ---------------------------------------------------------------------------
66#[cfg(feature = "pandoc-colink")]
67mod linked {
68    use super::*;
69
70    // Symbols absorbed at link time from libsnapper_pandoc.a (build-static.sh).
71    // Direct extern — not dlsym: static RTS symbols are not in the dynamic table.
72    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// ---------------------------------------------------------------------------
138// Dynamic load (default)
139// ---------------------------------------------------------------------------
140#[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        // Same directory as the running executable (Windows packaging: DLL beside .exe).
172        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
339// ---------------------------------------------------------------------------
340// Public API
341// ---------------------------------------------------------------------------
342
343/// Whether the in-process library can be used.
344pub 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
355/// Parse markup with the in-process pandoc library and classify via AST node kinds.
356pub 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
361/// Like [`parse_via_ffi`], also returns the pandoc JSON for caching.
362pub 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
375/// Explicit library-unavailable error for tests of the failure contract.
376pub 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        // Structural: either colink or dynamic path is compiled.
404        let _ = ffi_available();
405        #[cfg(feature = "pandoc-colink")]
406        {
407            // Co-linked builds should not require SNAPPER_PANDOC_LIB for discovery.
408            // Availability depends on the linked artifact existing at load time.
409        }
410    }
411
412    /// Colink is a *build* path: static archive absorb, not ldd→RUNPATH of libHS*.
413    #[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}