uncore 0.1.0

Shared C-ABI plumbing and error-kind conventions for the un* document extraction family
Documentation
  • Coverage
  • 100%
    29 out of 29 items documented8 out of 16 items with examples
  • Size
  • Source code size: 32.96 kB This is the summed size of all the files inside the crates.io package for this release.
  • Documentation size: 490.75 kB This is the summed size of all files generated by rustdoc for all configured targets
  • Ø build duration
  • this release: 7s Average build duration of successful builds.
  • all releases: 5s Average build duration of successful builds in releases after 2024-10-23.
  • Links
  • Homepage
  • iyulab/uncore
    0 0 0
  • crates.io
  • Dependencies
  • Versions
  • Owners
  • birdnamoo

uncore

Shared C-ABI plumbing and error-kind conventions for the un* document extraction family — unpdf (PDF), undoc (DOCX/XLSX/PPTX), unhwp (HWP/HWPX).

Why this exists

Those three libraries have the same shape — parse a container, build an intermediate representation, render Markdown — and expose it the same way: a Rust API, a C ABI, and C#, Python and WebAssembly bindings over that ABI.

The document parts differ entirely and belong in each library. The plumbing does not: thread-local last-error storage, catching panics before they cross extern "C", and the integer space the error classifications live in. That plumbing was written three times. This crate is where it lives once.

Scope

Two rules keep it from becoming a dumping ground:

  • No domain content. No document model, no rendering, no format knowledge. If a type would mention a page, a paragraph or a spreadsheet cell, it belongs upstream.
  • No dependencies. Three published cdylibs link this statically, so a dependency here is a dependency in all of them. Everything is std.

std-only (a CString in a thread_local!), which suits every consumer including wasm32-unknown-unknown.

What it gives you

use std::ffi::c_int;
use uncore::ffi::{self, FfiError, LastErrorSlot};

// The slot lives in your crate, not in uncore. That is what keeps it per-library —
// see below.
thread_local! {
    static LAST_ERROR: LastErrorSlot = const { LastErrorSlot::new() };
}

uncore::export_last_error_abi!(LAST_ERROR, mylib_last_error, mylib_last_error_kind);

/// Returns the page count, or -1 on failure.
#[no_mangle]
pub extern "C" fn mylib_page_count(handle: *const u8) -> c_int {
    let result: Result<c_int, FfiError> = ffi::catch(|| {
        if handle.is_null() {
            return Err(ffi::invalid_argument("handle was null"));
        }
        Ok(7)
    });

    match result {
        Ok(count) => {
            LAST_ERROR.with(|slot| slot.clear());
            count
        }
        Err(error) => {
            LAST_ERROR.with(|slot| slot.set_error(&error));
            -1
        }
    }
}
  • ffi::LastErrorSlot — message and kind, written together so a message is never paired with a stale reason.
  • ffi::catch — turns a panic into kind::PANIC instead of undefined behaviour.
  • ffi::FfiError(kind, message), carried out of closures so the classification is not lost to an early to_string().
  • export_last_error_abi! — declares <lib>_last_error and <lib>_last_error_kind.
  • assert_stable_kinds! — turns an accidental renumbering into a test failure.
  • kind — the values the family already shares, and bands for future ones.

Why the slot is yours

Each library ships as its own cdylib, so today each has its own statics. But this crate is linked into all of them, and a Rust binary depending on two would share one copy — at which point a single shared static would make unpdf_last_error() return whatever the preceding undoc call recorded. Isolation cannot rest on how consumers happen to link, so the slot is declared in the consuming crate. That makes it structural.

Error-kind numbering

kind exports only values that are already identical across the family, and that restraint is the design: a library adopting a constant whose value differs from what it ships would be renumbering its public ABI. Above UNKNOWN_FORMAT the family already disagrees — the low numbers were assigned independently, before anyone thought to align them — so any value published here would be a renumbering request aimed at somebody.

For everything else it offers bands rather than values:

Range Meaning
0 Success. Never a valid kind
1..=17 Assigned before the convention existed. Frozen where shipped
18..=99 New reasons that genuinely apply to more than one library
100..=199 Failures of the ABI call itself (100 invalid argument, 101 panic, 102 output cannot cross the ABI)
200.. One band per library, 100 wide — unpdf 200..=299, undoc 300..=399, unhwp 400..=499

The contract every library in the family promises its consumers:

  1. A new reason takes a new number. Existing numbers are never reused or renumbered.
  2. An unrecognised value is not an error — treat it as a generic failure and keep the number. A newer library stays usable by an older caller precisely because of this.
  3. 0 means success, never a reason.
  4. Reasons are #[non_exhaustive]: match with a _ => arm.

Versioning

0.x. Breaking changes are a normal means of getting the shape right, and the consumers are versioned independently.

License

MIT