Skip to main content

unparser_shared/
scaffold.rs

1//! Macros that assemble a C entry point out of the primitives in [`crate::ffi`].
2//!
3//! # What is repeated, and why a macro
4//!
5//! [`crate::ffi`] hands out the *materials* — a last-error slot, a panic guard, the two
6//! boundary failure reasons. Assembling them is five steps, and only one of them says
7//! anything about the library:
8//!
9//! 1. clear the slot, so a caller polling it does not read a previous failure. The slot is
10//!    cleared on entry, not on success: a body that itself calls another entry point on
11//!    this thread leaves that call's failure visible
12//! 2. reject a null handle with [`crate::ffi::invalid_argument`] and return the sentinel
13//! 3. run the body inside [`crate::ffi::catch`] ← the only step with domain content
14//! 4. move the result across the ABI, reporting [`crate::ffi::invalid_output`] if it cannot
15//! 5. record a failure and return the sentinel
16//!
17//! Steps 1, 2, 4 and 5 are about eighteen lines with no library content in them, and the
18//! sentinel in steps 2 and 5 differs by return type — null for a pointer, `-1` for a
19//! count. That is why these are several macros rather than one: a single general macro
20//! would have to be told the sentinel, and being told is how the wrong one gets used.
21//!
22//! # Names are spelled out, not built
23//!
24//! Every macro takes the exported function name in full, following
25//! [`crate::export_last_error_abi!`]: `macro_rules!` cannot concatenate identifiers, and
26//! an exported symbol appearing literally in the source is what someone greps for.
27//! Doc comments are passed through from the call site, because what an entry point means
28//! is not something this crate knows.
29
30/// Read a nullable C string argument as `&str`, classifying both ways it can fail.
31///
32/// Expands to a `Result`, so the usual form is `with_c_str!(path)?` inside a
33/// [`catch`](crate::ffi::catch) closure. The null message is built from the argument's own
34/// name — `with_c_str!(resource_id)` reports `"resource_id is null"`, which is the wording
35/// the family already uses.
36///
37/// Use this where the entry point has no out-parameter, so that classifying a null
38/// argument inside the closure is indistinguishable from classifying it before. Where
39/// there *is* an out-parameter, check for null first and use
40/// [`ffi::c_str_utf8`](crate::ffi::c_str_utf8) here instead — see
41/// [`export_bytes_getter!`](crate::export_bytes_getter).
42///
43/// # Safety
44///
45/// `$ptr` must be null or point to a NUL-terminated string. This expands to an unsafe
46/// read, so it has to appear in an `unsafe` context.
47///
48/// ```
49/// # use std::ffi::{c_char, CString};
50/// # use unparser_shared::ffi::FfiError;
51/// unsafe fn name_length(path: *const c_char) -> Result<usize, FfiError> {
52///     let path = unsafe { unparser_shared::with_c_str!(path) }?;
53///     Ok(path.len())
54/// }
55///
56/// let path = CString::new("a.txt").unwrap();
57/// assert_eq!(unsafe { name_length(path.as_ptr()) }.unwrap(), 5);
58/// assert_eq!(
59///     unsafe { name_length(std::ptr::null()) }.unwrap_err().1,
60///     "path is null"
61/// );
62/// ```
63#[macro_export]
64macro_rules! with_c_str {
65    ($ptr:ident) => {
66        if $ptr.is_null() {
67            ::std::result::Result::Err($crate::ffi::invalid_argument(concat!(
68                stringify!($ptr),
69                " is null"
70            )))
71        } else {
72            $crate::ffi::c_str_utf8($ptr)
73        }
74    };
75}
76
77/// Declare an opaque document handle and the function that frees it.
78///
79/// The handle is what a C caller holds: a `#[repr(C)]` newtype around the library's own
80/// document type with one private field. The field name is given rather than invented,
81/// because every entry point projects through it (`(*doc).inner`) and that has to be
82/// readable in the consuming source.
83///
84/// `#[repr(C)]` is emitted because the three shipped libraries declare it. It says
85/// nothing useful about a Rust field, and it is kept only so that adopting this macro
86/// leaves the declared type identical.
87///
88/// ```
89/// # struct Document;
90/// unparser_shared::export_handle! {
91///     /// Opaque handle to a parsed document.
92///     handle DemoDoc { inner: Document },
93///
94///     /// Free a document handle.
95///     ///
96///     /// # Safety
97///     ///
98///     /// - `doc` must be a pointer a parse function returned, or null.
99///     /// - After this call the handle is invalid and must not be used.
100///     free demo_release_document,
101/// }
102/// ```
103#[macro_export]
104macro_rules! export_handle {
105    (
106        $(#[$handle_meta:meta])*
107        handle $handle:ident { $field:ident: $inner:ty },
108
109        $(#[$free_meta:meta])*
110        free $free_fn:ident $(,)?
111    ) => {
112        $(#[$handle_meta])*
113        #[repr(C)]
114        pub struct $handle {
115            $field: $inner,
116        }
117
118        $(#[$free_meta])*
119        #[no_mangle]
120        pub unsafe extern "C" fn $free_fn(doc: *mut $handle) {
121            if !doc.is_null() {
122                // Wrapped explicitly rather than relying on the unsafe fn body: this
123                // expands both in crates that deny `unsafe_op_in_unsafe_fn` and in crates
124                // that do not, and a block around a genuinely unsafe operation is correct
125                // under either.
126                let _ = unsafe { ::std::boxed::Box::from_raw(doc) };
127            }
128        }
129    };
130}
131
132/// Declare the release function for strings this library hands out.
133///
134/// Every entry point returning `*mut c_char` transfers ownership, so exactly one of these
135/// belongs beside them. Reclaiming the allocation has to happen in the library that made
136/// it, which is why it cannot be `free()` on the caller's side.
137///
138/// ```
139/// unparser_shared::export_free_string!(
140///     /// Free a string produced by this library.
141///     ///
142///     /// # Safety
143///     ///
144///     /// - `s` must be a pointer this library returned, or null.
145///     demo_release_string
146/// );
147/// ```
148#[macro_export]
149macro_rules! export_free_string {
150    ($(#[$meta:meta])* $free_fn:ident) => {
151        $(#[$meta])*
152        #[no_mangle]
153        pub unsafe extern "C" fn $free_fn(s: *mut ::std::ffi::c_char) {
154            if !s.is_null() {
155                let _ = unsafe { ::std::ffi::CString::from_raw(s) };
156            }
157        }
158    };
159}
160
161/// Declare the release function for byte buffers this library hands out.
162///
163/// The length is taken back because the buffer was a `Box<[u8]>`: reconstructing it needs
164/// the same length the producing call reported. A zero length is ignored rather than
165/// reclaimed — an empty boxed slice has no allocation to return.
166///
167/// ```
168/// unparser_shared::export_free_bytes!(
169///     /// Free a byte buffer produced by this library.
170///     ///
171///     /// # Safety
172///     ///
173///     /// - `data` must be a pointer this library returned, or null.
174///     /// - `len` must be the length that call reported.
175///     demo_release_bytes
176/// );
177/// ```
178#[macro_export]
179macro_rules! export_free_bytes {
180    ($(#[$meta:meta])* $free_fn:ident) => {
181        $(#[$meta])*
182        #[no_mangle]
183        pub unsafe extern "C" fn $free_fn(data: *mut u8, len: usize) {
184            if !data.is_null() && len > 0 {
185                let _ = unsafe {
186                    ::std::boxed::Box::from_raw(::std::ptr::slice_from_raw_parts_mut(
187                        data, len,
188                    ))
189                };
190            }
191        }
192    };
193}
194
195/// Declare an entry point that hands out an owned string.
196///
197/// `$body` runs inside [`catch`](crate::ffi::catch) and evaluates to
198/// `Result<String, FfiError>`. Everything around it is the same in every such entry
199/// point: clear the slot, reject a null handle, move the string into a `CString`,
200/// report [`invalid_output`](crate::ffi::invalid_output) if it holds an interior NUL,
201/// record a failure and return null.
202///
203/// The handle parameter's name is taken from the call site so that `$body` can project
204/// through it — `{ Ok((*doc).inner.title()) }`. The handle is `*const`, since producing a
205/// string does not modify the document.
206///
207/// The null-handle message is `"document is null"`, fixed here so the family reports it
208/// identically — the same reason [`invalid_output`](crate::ffi::invalid_output)'s wording
209/// is fixed.
210///
211/// ```
212/// # use std::ffi::{c_int, CStr};
213/// # use unparser_shared::ffi::LastErrorSlot;
214/// # struct Report { lines: Vec<String> }
215/// # thread_local! { static LAST_ERROR: LastErrorSlot = const { LastErrorSlot::new() }; }
216/// # unparser_shared::export_last_error_abi!(LAST_ERROR, demo2_last_error, demo2_last_error_kind);
217/// # unparser_shared::export_handle! {
218/// #     /// Handle.
219/// #     handle Demo2Document { inner: Report },
220/// #     /// Free.
221/// #     ///
222/// #     /// # Safety
223/// #     /// `doc` must come from this library.
224/// #     free demo2_free_document,
225/// # }
226/// # unparser_shared::export_free_string!(
227/// #     /// Free.
228/// #     ///
229/// #     /// # Safety
230/// #     /// `s` must come from this library.
231/// #     demo2_free_string
232/// # );
233/// unparser_shared::export_string_getter!(
234///     /// The report's line at `index`, or a failure when it is out of range.
235///     ///
236///     /// # Safety
237///     ///
238///     /// - `doc` must be a valid handle.
239///     /// - The returned string must be freed with `demo2_free_string`.
240///     LAST_ERROR,
241///     demo2_line(doc: Demo2Document, index: c_int),
242///     {
243///         match unsafe { (&(*doc).inner.lines).get(index as usize) } {
244///             Some(line) => Ok(line.clone()),
245///             None => Err((unparser_shared::kind::OTHER, format!("no line {index}"))),
246///         }
247///     }
248/// );
249///
250/// let doc = Box::into_raw(Box::new(Demo2Document {
251///     inner: Report { lines: vec!["first".to_string()] },
252/// }));
253///
254/// let line = unsafe { demo2_line(doc, 0) };
255/// assert_eq!(unsafe { CStr::from_ptr(line) }.to_str().unwrap(), "first");
256/// unsafe { demo2_free_string(line) };
257///
258/// assert!(unsafe { demo2_line(doc, 9) }.is_null());
259/// assert_eq!(demo2_last_error_kind(), unparser_shared::kind::OTHER);
260///
261/// unsafe { demo2_free_document(doc) };
262/// ```
263#[macro_export]
264macro_rules! export_string_getter {
265    (
266        $(#[$meta:meta])*
267        $slot:path,
268        $name:ident($handle:ident: $handle_ty:ty $(, $arg:ident: $arg_ty:ty)* $(,)?),
269        $body:block
270    ) => {
271        $(#[$meta])*
272        #[no_mangle]
273        pub unsafe extern "C" fn $name(
274            $handle: *const $handle_ty,
275            $($arg: $arg_ty,)*
276        ) -> *mut ::std::ffi::c_char {
277            $slot.with(|slot| $crate::ffi::LastErrorSlot::clear(slot));
278
279            if $handle.is_null() {
280                $slot.with(|slot| {
281                    $crate::ffi::LastErrorSlot::set_error(
282                        slot,
283                        &$crate::ffi::invalid_argument("document is null"),
284                    )
285                });
286                return ::std::ptr::null_mut();
287            }
288
289            let produced: ::std::result::Result<::std::string::String, $crate::ffi::FfiError> =
290                $crate::ffi::catch(|| $body);
291
292            match produced {
293                ::std::result::Result::Ok(text) => match ::std::ffi::CString::new(text) {
294                    ::std::result::Result::Ok(owned) => owned.into_raw(),
295                    ::std::result::Result::Err(_) => {
296                        $slot.with(|slot| {
297                            $crate::ffi::LastErrorSlot::set_error(
298                                slot,
299                                &$crate::ffi::invalid_output(),
300                            )
301                        });
302                        ::std::ptr::null_mut()
303                    }
304                },
305                ::std::result::Result::Err(error) => {
306                    $slot.with(|slot| $crate::ffi::LastErrorSlot::set_error(slot, &error));
307                    ::std::ptr::null_mut()
308                }
309            }
310        }
311    };
312}
313
314/// Declare an entry point that hands out a string which may legitimately be absent.
315///
316/// `$body` evaluates to `Result<Option<String>, FfiError>`. `Ok(None)` returns null with
317/// the slot left at success, because absence is not a failure — document metadata that was
318/// never set is the case this exists for.
319///
320/// That makes null ambiguous on its own, and deliberately so: the kind channel is what
321/// separates the three outcomes a caller has to tell apart.
322///
323/// | `$body` | Returns | Kind |
324/// |---|---|---|
325/// | `Ok(Some(text))` | the string | success |
326/// | `Ok(None)` | null | success — there is nothing to give |
327/// | `Ok(Some(text))` holding an interior NUL | null | [`kind::INVALID_OUTPUT`](crate::kind::INVALID_OUTPUT) |
328/// | `Err(..)` | null | whatever the body classified |
329///
330/// Use [`export_string_getter!`](crate::export_string_getter) where absence cannot happen
331/// or is genuinely an empty string. Do not fold the two by defaulting absent to `""` —
332/// that tells a caller the field is set and blank.
333///
334/// ```
335/// # use unparser_shared::ffi::LastErrorSlot;
336/// # struct Meta { author: Option<String> }
337/// # thread_local! { static LAST_ERROR: LastErrorSlot = const { LastErrorSlot::new() }; }
338/// # unparser_shared::export_last_error_abi!(LAST_ERROR, demo3_last_error, demo3_last_error_kind);
339/// # unparser_shared::export_handle! {
340/// #     /// Handle.
341/// #     handle Demo3Document { inner: Meta },
342/// #     /// Free.
343/// #     ///
344/// #     /// # Safety
345/// #     /// `doc` must come from this library.
346/// #     free demo3_free_document,
347/// # }
348/// unparser_shared::export_optional_string_getter!(
349///     /// The document author, or null when none is recorded.
350///     ///
351///     /// # Safety
352///     ///
353///     /// - `doc` must be a valid handle.
354///     /// - The returned string must be freed by this library.
355///     LAST_ERROR,
356///     demo3_author(doc: Demo3Document),
357///     { Ok(unsafe { (*doc).inner.author.clone() }) }
358/// );
359///
360/// let absent = Box::into_raw(Box::new(Demo3Document { inner: Meta { author: None } }));
361/// assert!(unsafe { demo3_author(absent) }.is_null());
362/// assert_eq!(demo3_last_error_kind(), unparser_shared::kind::NONE);
363/// unsafe { demo3_free_document(absent) };
364/// ```
365#[macro_export]
366macro_rules! export_optional_string_getter {
367    (
368        $(#[$meta:meta])*
369        $slot:path,
370        $name:ident($handle:ident: $handle_ty:ty $(, $arg:ident: $arg_ty:ty)* $(,)?),
371        $body:block
372    ) => {
373        $(#[$meta])*
374        #[no_mangle]
375        pub unsafe extern "C" fn $name(
376            $handle: *const $handle_ty,
377            $($arg: $arg_ty,)*
378        ) -> *mut ::std::ffi::c_char {
379            $slot.with(|slot| $crate::ffi::LastErrorSlot::clear(slot));
380
381            if $handle.is_null() {
382                $slot.with(|slot| {
383                    $crate::ffi::LastErrorSlot::set_error(
384                        slot,
385                        &$crate::ffi::invalid_argument("document is null"),
386                    )
387                });
388                return ::std::ptr::null_mut();
389            }
390
391            let produced: ::std::result::Result<
392                ::std::option::Option<::std::string::String>,
393                $crate::ffi::FfiError,
394            > = $crate::ffi::catch(|| $body);
395
396            match produced {
397                ::std::result::Result::Ok(::std::option::Option::Some(text)) => {
398                    match ::std::ffi::CString::new(text) {
399                        ::std::result::Result::Ok(owned) => owned.into_raw(),
400                        ::std::result::Result::Err(_) => {
401                            $slot.with(|slot| {
402                                $crate::ffi::LastErrorSlot::set_error(
403                                    slot,
404                                    &$crate::ffi::invalid_output(),
405                                )
406                            });
407                            ::std::ptr::null_mut()
408                        }
409                    }
410                }
411                ::std::result::Result::Ok(::std::option::Option::None) => {
412                    ::std::ptr::null_mut()
413                }
414                ::std::result::Result::Err(error) => {
415                    $slot.with(|slot| $crate::ffi::LastErrorSlot::set_error(slot, &error));
416                    ::std::ptr::null_mut()
417                }
418            }
419        }
420    };
421}
422
423/// Declare an entry point that hands out a count.
424///
425/// `$body` evaluates to `Result<c_int, FfiError>`. Failure — including a null handle —
426/// returns `-1`, which is why this is its own macro rather than a sentinel argument to a
427/// general one: a sentinel that can be passed in is a sentinel that can be passed wrong.
428///
429/// The slot is cleared on entry like everywhere else, and here that is load-bearing rather
430/// than tidy. A count getter returns a value instead of a pointer, so a caller polling
431/// `<lib>_last_error_kind` after it has no other way to know the recorded failure is not
432/// someone else's.
433///
434/// ```
435/// # use unparser_shared::ffi::LastErrorSlot;
436/// # struct Book { pages: Vec<u8> }
437/// # thread_local! { static LAST_ERROR: LastErrorSlot = const { LastErrorSlot::new() }; }
438/// # unparser_shared::export_last_error_abi!(LAST_ERROR, demo4_last_error, demo4_last_error_kind);
439/// # unparser_shared::export_handle! {
440/// #     /// Handle.
441/// #     handle Demo4Document { inner: Book },
442/// #     /// Free.
443/// #     ///
444/// #     /// # Safety
445/// #     /// `doc` must come from this library.
446/// #     free demo4_free_document,
447/// # }
448/// unparser_shared::export_count_getter!(
449///     /// The number of pages, or -1 on failure.
450///     ///
451///     /// # Safety
452///     ///
453///     /// - `doc` must be a valid handle.
454///     LAST_ERROR,
455///     demo4_page_count(doc: Demo4Document),
456///     { Ok(unsafe { (*doc).inner.pages.len() } as std::ffi::c_int) }
457/// );
458///
459/// assert_eq!(unsafe { demo4_page_count(std::ptr::null()) }, -1);
460/// assert_eq!(demo4_last_error_kind(), unparser_shared::kind::INVALID_ARGUMENT);
461///
462/// let doc = Box::into_raw(Box::new(Demo4Document { inner: Book { pages: vec![1, 2] } }));
463/// assert_eq!(unsafe { demo4_page_count(doc) }, 2);
464/// assert_eq!(demo4_last_error_kind(), unparser_shared::kind::NONE);
465/// unsafe { demo4_free_document(doc) };
466/// ```
467#[macro_export]
468macro_rules! export_count_getter {
469    (
470        $(#[$meta:meta])*
471        $slot:path,
472        $name:ident($handle:ident: $handle_ty:ty $(, $arg:ident: $arg_ty:ty)* $(,)?),
473        $body:block
474    ) => {
475        $(#[$meta])*
476        #[no_mangle]
477        pub unsafe extern "C" fn $name(
478            $handle: *const $handle_ty,
479            $($arg: $arg_ty,)*
480        ) -> ::std::ffi::c_int {
481            $slot.with(|slot| $crate::ffi::LastErrorSlot::clear(slot));
482
483            if $handle.is_null() {
484                $slot.with(|slot| {
485                    $crate::ffi::LastErrorSlot::set_error(
486                        slot,
487                        &$crate::ffi::invalid_argument("document is null"),
488                    )
489                });
490                return -1;
491            }
492
493            let counted: ::std::result::Result<::std::ffi::c_int, $crate::ffi::FfiError> =
494                $crate::ffi::catch(|| $body);
495
496            match counted {
497                ::std::result::Result::Ok(count) => count,
498                ::std::result::Result::Err(error) => {
499                    $slot.with(|slot| $crate::ffi::LastErrorSlot::set_error(slot, &error));
500                    -1
501                }
502            }
503        }
504    };
505}
506
507/// Declare an entry point that hands out an owned byte buffer selected by a C-string key.
508///
509/// The signature is fixed — `(handle, key, out_len) -> *mut u8` — because that is the
510/// shape every consumer of this ABI already exports, and only the names come from the call
511/// site. An entry point needing a different shape is written by hand.
512///
513/// `$body` evaluates to `Result<Vec<u8>, FfiError>`. The macro moves that into a
514/// `Box<[u8]>`, writes its length through `out_len`, and hands over the pointer; the
515/// matching [`export_free_bytes!`](crate::export_free_bytes) reclaims it from the same two
516/// values.
517///
518/// An empty `Vec` is a success like any other: it is handed over as a non-null pointer with
519/// `out_len` set to `0`, distinct from the null this returns on failure. A caller that
520/// treats a zero length as absence will misread an empty result as one that was never
521/// produced.
522///
523/// # Why every argument is checked before the closure
524///
525/// The failure path writes `*out_len = 0`, so *when* an argument is rejected is
526/// observable. All three null checks therefore happen before
527/// [`catch`](crate::ffi::catch) runs, and a rejected argument leaves the caller's length
528/// variable exactly as they left it — "not attempted" stays distinguishable from
529/// "attempted and produced nothing".
530///
531/// That is also why `$body` reads the key with
532/// [`ffi::c_str_utf8`](crate::ffi::c_str_utf8) rather than
533/// [`with_c_str!`](crate::with_c_str): the null check is already done, and the UTF-8
534/// conversion belongs inside the closure, where its failure does zero the length.
535///
536/// ```
537/// # use std::collections::HashMap;
538/// # use std::ffi::CString;
539/// # use unparser_shared::ffi::LastErrorSlot;
540/// # struct Archive { entries: HashMap<String, Vec<u8>> }
541/// # thread_local! { static LAST_ERROR: LastErrorSlot = const { LastErrorSlot::new() }; }
542/// # unparser_shared::export_last_error_abi!(LAST_ERROR, demo5_last_error, demo5_last_error_kind);
543/// # unparser_shared::export_handle! {
544/// #     /// Handle.
545/// #     handle Demo5Document { inner: Archive },
546/// #     /// Free.
547/// #     ///
548/// #     /// # Safety
549/// #     /// `doc` must come from this library.
550/// #     free demo5_free_document,
551/// # }
552/// # unparser_shared::export_free_bytes!(
553/// #     /// Free.
554/// #     ///
555/// #     /// # Safety
556/// #     /// `data` and `len` must come from this library.
557/// #     demo5_free_bytes
558/// # );
559/// unparser_shared::export_bytes_getter!(
560///     /// The bytes of the entry named `name`.
561///     ///
562///     /// # Safety
563///     ///
564///     /// - `doc` must be a valid handle.
565///     /// - `name` must be a valid null-terminated UTF-8 string.
566///     /// - `out_len` must point to storage for the length.
567///     /// - The returned pointer must be freed with `demo5_free_bytes`.
568///     LAST_ERROR,
569///     demo5_entry(doc: Demo5Document, name, out out_len),
570///     {
571///         let name = unsafe { unparser_shared::ffi::c_str_utf8(name) }?;
572///         match unsafe { (*doc).inner.entries.get(name) } {
573///             Some(bytes) => Ok(bytes.clone()),
574///             None => Err((unparser_shared::kind::OTHER, format!("no entry {name}"))),
575///         }
576///     }
577/// );
578///
579/// let mut entries = HashMap::new();
580/// entries.insert("a.bin".to_string(), vec![1u8, 2, 3]);
581/// let doc = Box::into_raw(Box::new(Demo5Document { inner: Archive { entries } }));
582/// let name = CString::new("a.bin").unwrap();
583///
584/// let mut out_len = 0usize;
585/// let bytes = unsafe { demo5_entry(doc, name.as_ptr(), &mut out_len) };
586/// assert_eq!(out_len, 3);
587/// assert_eq!(unsafe { std::slice::from_raw_parts(bytes, out_len) }, [1, 2, 3]);
588///
589/// unsafe {
590///     demo5_free_bytes(bytes, out_len);
591///     demo5_free_document(doc);
592/// }
593/// ```
594#[macro_export]
595macro_rules! export_bytes_getter {
596    (
597        $(#[$meta:meta])*
598        $slot:path,
599        $name:ident($handle:ident: $handle_ty:ty, $key:ident, out $out_len:ident $(,)?),
600        $body:block
601    ) => {
602        $(#[$meta])*
603        #[no_mangle]
604        pub unsafe extern "C" fn $name(
605            $handle: *const $handle_ty,
606            $key: *const ::std::ffi::c_char,
607            $out_len: *mut usize,
608        ) -> *mut u8 {
609            $slot.with(|slot| $crate::ffi::LastErrorSlot::clear(slot));
610
611            // Checked before the closure: the failure path below writes through
612            // `$out_len`, so a rejected argument must leave it as the caller left it.
613            if $handle.is_null() {
614                $slot.with(|slot| {
615                    $crate::ffi::LastErrorSlot::set_error(
616                        slot,
617                        &$crate::ffi::invalid_argument("document is null"),
618                    )
619                });
620                return ::std::ptr::null_mut();
621            }
622
623            if $key.is_null() {
624                $slot.with(|slot| {
625                    $crate::ffi::LastErrorSlot::set_error(
626                        slot,
627                        &$crate::ffi::invalid_argument(concat!(
628                            stringify!($key),
629                            " is null"
630                        )),
631                    )
632                });
633                return ::std::ptr::null_mut();
634            }
635
636            if $out_len.is_null() {
637                $slot.with(|slot| {
638                    $crate::ffi::LastErrorSlot::set_error(
639                        slot,
640                        &$crate::ffi::invalid_argument(concat!(
641                            stringify!($out_len),
642                            " is null"
643                        )),
644                    )
645                });
646                return ::std::ptr::null_mut();
647            }
648
649            let produced: ::std::result::Result<
650                ::std::vec::Vec<u8>,
651                $crate::ffi::FfiError,
652            > = $crate::ffi::catch(|| $body);
653
654            match produced {
655                ::std::result::Result::Ok(data) => {
656                    let length = data.len();
657                    let raw =
658                        ::std::boxed::Box::into_raw(data.into_boxed_slice()) as *mut u8;
659                    // The null check above is what makes this write sound; wrapped
660                    // explicitly so the expansion compiles under
661                    // `unsafe_op_in_unsafe_fn` too.
662                    unsafe { *$out_len = length };
663                    raw
664                }
665                ::std::result::Result::Err(error) => {
666                    $slot.with(|slot| $crate::ffi::LastErrorSlot::set_error(slot, &error));
667                    unsafe { *$out_len = 0 };
668                    ::std::ptr::null_mut()
669                }
670            }
671        }
672    };
673}
674
675#[cfg(test)]
676mod tests {
677    use std::ffi::{c_char, CString};
678
679    use crate::ffi::FfiError;
680
681    unsafe fn read(ptr: *const c_char) -> Result<&'static str, FfiError> {
682        unsafe { crate::with_c_str!(ptr) }
683    }
684
685    #[test]
686    fn a_null_c_string_argument_is_named_in_its_own_message() {
687        let failure = unsafe { read(std::ptr::null()) }.unwrap_err();
688        assert_eq!(failure.0, crate::kind::INVALID_ARGUMENT);
689        assert_eq!(
690            failure.1, "ptr is null",
691            "the message is built from the argument's own name"
692        );
693    }
694
695    #[test]
696    fn a_valid_c_string_argument_reads_through() {
697        let text = CString::new("document.hwp").unwrap();
698        assert_eq!(unsafe { read(text.as_ptr()) }.unwrap(), "document.hwp");
699    }
700
701    #[test]
702    fn a_non_utf8_c_string_argument_is_an_invalid_argument() {
703        // 0xFF is not a valid UTF-8 lead byte.
704        let raw = [0xFFu8, 0x00];
705        let failure = unsafe { read(raw.as_ptr() as *const c_char) }.unwrap_err();
706        assert_eq!(failure.0, crate::kind::INVALID_ARGUMENT);
707        assert!(
708            !failure.1.is_empty(),
709            "the conversion error must survive as a message"
710        );
711    }
712
713    use std::collections::HashMap;
714
715    use crate::ffi::LastErrorSlot;
716
717    /// Stands in for a library's document. Deliberately not a document: the fields are
718    /// only the *shapes* an entry point returns — a string, an absent string, a count,
719    /// a byte buffer keyed by name.
720    #[derive(Default)]
721    struct Demo {
722        body: String,
723        title: Option<String>,
724        blobs: HashMap<String, Vec<u8>>,
725    }
726
727    thread_local! {
728        static DEMO_ERROR: LastErrorSlot = const { LastErrorSlot::new() };
729    }
730
731    crate::export_last_error_abi!(DEMO_ERROR, demo_last_error, demo_last_error_kind);
732
733    crate::export_handle! {
734        /// Opaque handle to a demo document.
735        handle DemoDocument { inner: Demo },
736
737        /// Free a demo handle.
738        ///
739        /// # Safety
740        ///
741        /// - `doc` must be a pointer this test module produced, or null.
742        /// - After this call the handle is invalid and must not be used.
743        free demo_free_document,
744    }
745
746    crate::export_free_string!(
747        /// Free a string produced by a demo entry point.
748        ///
749        /// # Safety
750        ///
751        /// - `s` must be a pointer a demo entry point returned, or null.
752        demo_free_string
753    );
754
755    crate::export_free_bytes!(
756        /// Free a byte buffer produced by a demo entry point.
757        ///
758        /// # Safety
759        ///
760        /// - `data` must be a pointer a demo entry point returned, or null.
761        /// - `len` must be the length that call reported.
762        demo_free_bytes
763    );
764
765    fn demo(inner: Demo) -> *mut DemoDocument {
766        Box::into_raw(Box::new(DemoDocument { inner }))
767    }
768
769    #[test]
770    fn a_handle_round_trips_through_its_free_function() {
771        let doc = demo(Demo::default());
772        assert!(!doc.is_null());
773        // A smoke test, deliberately: a stock `cargo test` run has no leak detector, so
774        // this cannot show the allocation was returned. What it does show is that the
775        // pair agrees on the pointer — a mismatched type or an extra indirection would
776        // abort here.
777        unsafe { demo_free_document(doc) };
778    }
779
780    #[test]
781    fn every_free_function_accepts_null() {
782        unsafe {
783            demo_free_document(std::ptr::null_mut());
784            demo_free_string(std::ptr::null_mut());
785            demo_free_bytes(std::ptr::null_mut(), 0);
786        }
787    }
788
789    /// A zero length must leave the caller's buffer alone.
790    ///
791    /// This pins the observable contract, not the guard: dropping a zero-length
792    /// `Box<[u8]>` would not reach the allocator either, since `Global::deallocate`
793    /// skips a zero-size layout. The `len > 0` guard earns its keep elsewhere — it means
794    /// no `Box` is ever reconstructed from a pointer at all, which is what a
795    /// provenance-checking run would object to.
796    #[test]
797    fn free_bytes_ignores_a_zero_length_buffer() {
798        let mut byte = 7u8;
799        unsafe { demo_free_bytes(&mut byte, 0) };
800        assert_eq!(byte, 7);
801    }
802
803    crate::export_string_getter!(
804        /// The document body.
805        ///
806        /// # Safety
807        ///
808        /// - `doc` must be a valid handle.
809        /// - The returned string must be freed with `demo_free_string`.
810        DEMO_ERROR,
811        demo_body(doc: DemoDocument),
812        { Ok(unsafe { (*doc).inner.body.clone() }) }
813    );
814
815    crate::export_string_getter!(
816        /// The document body, repeated `times` times.
817        ///
818        /// # Safety
819        ///
820        /// - `doc` must be a valid handle.
821        /// - The returned string must be freed with `demo_free_string`.
822        DEMO_ERROR,
823        demo_body_repeated(doc: DemoDocument, times: ::std::ffi::c_int),
824        { Ok(unsafe { (*doc).inner.body.repeat(times.max(0) as usize) }) }
825    );
826
827    crate::export_string_getter!(
828        /// The document body, or a classified failure when it is empty.
829        ///
830        /// # Safety
831        ///
832        /// - `doc` must be a valid handle.
833        DEMO_ERROR,
834        demo_body_or_fail(doc: DemoDocument),
835        {
836            let body = unsafe { &(*doc).inner.body };
837            if body.is_empty() {
838                return Err((crate::kind::IO, "no body".to_string()));
839            }
840            Ok(body.clone())
841        }
842    );
843
844    crate::export_string_getter!(
845        /// Always panics, to prove the guard is in place.
846        ///
847        /// # Safety
848        ///
849        /// - `doc` must be a valid handle.
850        // Passed through to the generated item, which also proves the macro forwards
851        // arbitrary attributes and not only doc comments.
852        #[allow(unreachable_code)]
853        DEMO_ERROR,
854        demo_body_panics(doc: DemoDocument),
855        { panic!("deliberate") }
856    );
857
858    fn owned(text: *mut c_char) -> String {
859        assert!(!text.is_null());
860        let owned = unsafe { std::ffi::CStr::from_ptr(text) }
861            .to_string_lossy()
862            .into_owned();
863        unsafe { demo_free_string(text) };
864        owned
865    }
866
867    fn demo_with_body(body: &str) -> *mut DemoDocument {
868        demo(Demo {
869            body: body.to_string(),
870            ..Demo::default()
871        })
872    }
873
874    #[test]
875    fn a_string_getter_returns_its_value_and_reports_success() {
876        let doc = demo_with_body("hello");
877        assert_eq!(owned(unsafe { demo_body(doc) }), "hello");
878        assert_eq!(demo_last_error_kind(), crate::kind::NONE);
879        unsafe { demo_free_document(doc) };
880    }
881
882    #[test]
883    fn a_string_getter_passes_its_extra_arguments_to_the_body() {
884        let doc = demo_with_body("ab");
885        assert_eq!(owned(unsafe { demo_body_repeated(doc, 3) }), "ababab");
886        unsafe { demo_free_document(doc) };
887    }
888
889    #[test]
890    fn a_null_handle_is_an_invalid_argument_named_document() {
891        assert!(unsafe { demo_body(std::ptr::null()) }.is_null());
892        assert_eq!(demo_last_error_kind(), crate::kind::INVALID_ARGUMENT);
893        let message = unsafe { std::ffi::CStr::from_ptr(demo_last_error()) }
894            .to_string_lossy()
895            .into_owned();
896        assert_eq!(
897            message, "document is null",
898            "the family reports this identically, so the wording is fixed here"
899        );
900    }
901
902    /// The reason a caller can poll the slot after a *successful* call: it was cleared on
903    /// entry, so a leftover failure cannot be attributed to this call.
904    #[test]
905    fn a_successful_call_clears_a_previous_failure() {
906        assert!(unsafe { demo_body(std::ptr::null()) }.is_null());
907        assert_ne!(demo_last_error_kind(), crate::kind::NONE);
908
909        let doc = demo_with_body("fresh");
910        assert_eq!(owned(unsafe { demo_body(doc) }), "fresh");
911        assert_eq!(demo_last_error_kind(), crate::kind::NONE);
912        unsafe { demo_free_document(doc) };
913    }
914
915    #[test]
916    fn a_classified_body_failure_reaches_the_slot() {
917        let doc = demo_with_body("");
918        assert!(unsafe { demo_body_or_fail(doc) }.is_null());
919        assert_eq!(demo_last_error_kind(), crate::kind::IO);
920        unsafe { demo_free_document(doc) };
921    }
922
923    /// An interior NUL is the one failure the macro raises by itself: the value exists and
924    /// is correct, it just cannot be a C string.
925    #[test]
926    fn a_value_holding_an_interior_nul_is_reported_not_truncated() {
927        let doc = demo_with_body("before\0after");
928        assert!(unsafe { demo_body(doc) }.is_null());
929        assert_eq!(demo_last_error_kind(), crate::kind::INVALID_OUTPUT);
930        unsafe { demo_free_document(doc) };
931    }
932
933    #[test]
934    fn a_panic_in_the_body_does_not_cross_the_boundary() {
935        let previous = std::panic::take_hook();
936        std::panic::set_hook(Box::new(|_| {}));
937        let doc = demo_with_body("x");
938        let returned = unsafe { demo_body_panics(doc) };
939        std::panic::set_hook(previous);
940
941        assert!(returned.is_null());
942        assert_eq!(demo_last_error_kind(), crate::kind::PANIC);
943        unsafe { demo_free_document(doc) };
944    }
945
946    crate::export_optional_string_getter!(
947        /// The document title.
948        ///
949        /// # Safety
950        ///
951        /// - `doc` must be a valid handle.
952        /// - Returns null when no title is set, leaving the kind at success.
953        /// - The returned string must be freed with `demo_free_string`.
954        DEMO_ERROR,
955        demo_title(doc: DemoDocument),
956        { Ok(unsafe { (*doc).inner.title.clone() }) }
957    );
958
959    fn demo_with_title(title: Option<&str>) -> *mut DemoDocument {
960        demo(Demo {
961            title: title.map(str::to_string),
962            ..Demo::default()
963        })
964    }
965
966    #[test]
967    fn a_present_optional_value_is_handed_out() {
968        let doc = demo_with_title(Some("Quarterly Report"));
969        assert_eq!(owned(unsafe { demo_title(doc) }), "Quarterly Report");
970        assert_eq!(demo_last_error_kind(), crate::kind::NONE);
971        unsafe { demo_free_document(doc) };
972    }
973
974    /// An absent value is not a failure. This is the whole reason this macro is separate
975    /// from `export_string_getter!`: collapsing absent to an empty string would tell a
976    /// caller there is a title and it is blank.
977    #[test]
978    fn an_absent_optional_value_is_null_and_still_success() {
979        let doc = demo_with_title(None);
980        assert!(unsafe { demo_title(doc) }.is_null());
981        assert_eq!(demo_last_error_kind(), crate::kind::NONE);
982        unsafe { demo_free_document(doc) };
983    }
984
985    /// The counterpart: a value that exists but cannot cross must not read as absent.
986    /// Both return null, so the kind is the only thing that separates "there is nothing"
987    /// from "we could not give it to you".
988    #[test]
989    fn an_unrepresentable_optional_value_is_not_reported_as_absent() {
990        let doc = demo_with_title(Some("has\0interior nul"));
991        assert!(unsafe { demo_title(doc) }.is_null());
992        assert_eq!(demo_last_error_kind(), crate::kind::INVALID_OUTPUT);
993        unsafe { demo_free_document(doc) };
994    }
995
996    #[test]
997    fn an_optional_getter_rejects_a_null_handle() {
998        assert!(unsafe { demo_title(std::ptr::null()) }.is_null());
999        assert_eq!(demo_last_error_kind(), crate::kind::INVALID_ARGUMENT);
1000    }
1001
1002    crate::export_count_getter!(
1003        /// The number of blobs in the document.
1004        ///
1005        /// # Safety
1006        ///
1007        /// - `doc` must be a valid handle.
1008        /// - Returns -1 on failure.
1009        DEMO_ERROR,
1010        demo_blob_count(doc: DemoDocument),
1011        { Ok(unsafe { (*doc).inner.blobs.len() } as ::std::ffi::c_int) }
1012    );
1013
1014    fn demo_with_blob(id: &str, data: &[u8]) -> *mut DemoDocument {
1015        let mut blobs = HashMap::new();
1016        blobs.insert(id.to_string(), data.to_vec());
1017        demo(Demo {
1018            blobs,
1019            ..Demo::default()
1020        })
1021    }
1022
1023    #[test]
1024    fn a_count_getter_returns_its_count() {
1025        let doc = demo_with_blob("logo.png", b"\x89PNG");
1026        assert_eq!(unsafe { demo_blob_count(doc) }, 1);
1027        assert_eq!(demo_last_error_kind(), crate::kind::NONE);
1028        unsafe { demo_free_document(doc) };
1029    }
1030
1031    #[test]
1032    fn a_count_getter_returns_minus_one_for_a_null_handle() {
1033        assert_eq!(unsafe { demo_blob_count(std::ptr::null()) }, -1);
1034        assert_eq!(demo_last_error_kind(), crate::kind::INVALID_ARGUMENT);
1035    }
1036
1037    /// A count getter returns a value, not a pointer, so a caller cannot tell from the
1038    /// return alone whether a recorded failure is theirs. Clearing on entry is what makes
1039    /// polling the slot after a count meaningful — the shipped ABI does this and it is
1040    /// easy to drop when the body is one line.
1041    #[test]
1042    fn a_count_getter_clears_a_previous_failure() {
1043        assert_eq!(unsafe { demo_blob_count(std::ptr::null()) }, -1);
1044        assert_ne!(demo_last_error_kind(), crate::kind::NONE);
1045
1046        let doc = demo(Demo::default());
1047        assert_eq!(unsafe { demo_blob_count(doc) }, 0);
1048        assert_eq!(
1049            demo_last_error_kind(),
1050            crate::kind::NONE,
1051            "a count of zero is a success, not a leftover failure"
1052        );
1053        unsafe { demo_free_document(doc) };
1054    }
1055
1056    crate::export_bytes_getter!(
1057        /// The bytes of the blob named `id`.
1058        ///
1059        /// # Safety
1060        ///
1061        /// - `doc` must be a valid handle.
1062        /// - `id` must be a valid null-terminated UTF-8 string.
1063        /// - `out_len` must point to storage for the length.
1064        /// - The returned pointer must be freed with `demo_free_bytes`.
1065        DEMO_ERROR,
1066        demo_blob(doc: DemoDocument, id, out out_len),
1067        {
1068            let id = unsafe { crate::ffi::c_str_utf8(id) }?;
1069            match unsafe { (*doc).inner.blobs.get(id) } {
1070                Some(data) => Ok(data.clone()),
1071                None => Err((crate::kind::OTHER, format!("no blob {id}"))),
1072            }
1073        }
1074    );
1075
1076    #[test]
1077    fn a_bytes_getter_hands_over_the_buffer_and_its_length() {
1078        let doc = demo_with_blob("logo.png", b"\x89PNG\r\n");
1079        let id = CString::new("logo.png").unwrap();
1080
1081        let mut out_len: usize = 0;
1082        let data = unsafe { demo_blob(doc, id.as_ptr(), &mut out_len) };
1083        assert!(!data.is_null());
1084        assert_eq!(out_len, 6);
1085        assert_eq!(
1086            unsafe { std::slice::from_raw_parts(data, out_len) },
1087            b"\x89PNG\r\n"
1088        );
1089
1090        unsafe {
1091            demo_free_bytes(data, out_len);
1092            demo_free_document(doc);
1093        }
1094    }
1095
1096    /// The behaviour this macro checks all three arguments before the closure for: a
1097    /// rejected argument leaves the caller's length variable exactly as they left it, so
1098    /// "not attempted" stays distinguishable from "attempted and produced nothing".
1099    #[test]
1100    fn a_rejected_argument_leaves_out_len_untouched() {
1101        let doc = demo_with_blob("logo.png", b"\x89PNG");
1102        let id = CString::new("logo.png").unwrap();
1103        const SEEDED: usize = 0xDEAD;
1104
1105        let mut out_len: usize = SEEDED;
1106        assert!(unsafe { demo_blob(std::ptr::null(), id.as_ptr(), &mut out_len) }.is_null());
1107        assert_eq!(out_len, SEEDED, "a null handle must not write out_len");
1108        assert_eq!(demo_last_error_kind(), crate::kind::INVALID_ARGUMENT);
1109
1110        assert!(unsafe { demo_blob(doc, std::ptr::null(), &mut out_len) }.is_null());
1111        assert_eq!(out_len, SEEDED, "a null key must not write out_len");
1112        assert_eq!(demo_last_error_kind(), crate::kind::INVALID_ARGUMENT);
1113
1114        // A key that is merely absent *is* attempted, so the length is zeroed.
1115        let missing = CString::new("absent.png").unwrap();
1116        assert!(unsafe { demo_blob(doc, missing.as_ptr(), &mut out_len) }.is_null());
1117        assert_eq!(out_len, 0, "a lookup that failed reports zero length");
1118        assert_eq!(demo_last_error_kind(), crate::kind::OTHER);
1119
1120        unsafe { demo_free_document(doc) };
1121    }
1122
1123    #[test]
1124    fn a_null_out_len_is_rejected_and_named() {
1125        let doc = demo_with_blob("logo.png", b"\x89PNG");
1126        let id = CString::new("logo.png").unwrap();
1127
1128        assert!(unsafe { demo_blob(doc, id.as_ptr(), std::ptr::null_mut()) }.is_null());
1129        let message = unsafe { std::ffi::CStr::from_ptr(demo_last_error()) }
1130            .to_string_lossy()
1131            .into_owned();
1132        assert_eq!(message, "out_len is null");
1133
1134        unsafe { demo_free_document(doc) };
1135    }
1136
1137    #[test]
1138    fn a_non_utf8_key_is_classified_and_zeroes_the_length() {
1139        let doc = demo_with_blob("logo.png", b"\x89PNG");
1140        let raw = [0xFFu8, 0x00];
1141
1142        let mut out_len: usize = 0xBEEF;
1143        let data = unsafe { demo_blob(doc, raw.as_ptr() as *const c_char, &mut out_len) };
1144        assert!(data.is_null());
1145        assert_eq!(demo_last_error_kind(), crate::kind::INVALID_ARGUMENT);
1146        assert_eq!(
1147            out_len, 0,
1148            "the conversion happens inside the closure, so the failure path runs"
1149        );
1150
1151        unsafe { demo_free_document(doc) };
1152    }
1153
1154    #[test]
1155    fn an_empty_buffer_is_handed_over_as_a_non_null_pointer_with_zero_length() {
1156        let doc = demo_with_blob("empty.bin", b"");
1157        let id = CString::new("empty.bin").unwrap();
1158
1159        let mut out_len: usize = 9;
1160        let data = unsafe { demo_blob(doc, id.as_ptr(), &mut out_len) };
1161        assert!(
1162            !data.is_null(),
1163            "an empty blob is a success, not an absent one"
1164        );
1165        assert_eq!(out_len, 0);
1166
1167        unsafe {
1168            demo_free_bytes(data, out_len);
1169            demo_free_document(doc);
1170        }
1171    }
1172}