code-native 2.18.3

Write native .so modules for the Code programming language in Rust — safe CodeValue builders/readers over the real runtime.c, no reimplementation.
Documentation

code-native

Write a native .so module for the Code programming language in Rust — link "x.so" as m

  • emit particle to m from .code source, with the handler behind m implemented here instead of C.

This is the Rust-specific path into code's native-module ABI (src/code_abi.h in the main repo). C — and anything else that can produce a C-ABI shared library — still uses that header and src/runtime.c directly; there's no package registry to publish a C-language bundle to, so that story is unchanged. This crate exists so a Rust module doesn't need a checkout of the code repo at all: cargo add code-native pulls in everything required, including a real runtime.c compiled and linked in by this crate's build.rs — not a Rust reimplementation of it, so there's no risk of the refcounting subtly drifting from what the host trusts.

Quick start

# Cargo.toml
[lib]
crate-type = ["cdylib"]

[dependencies]
code-native = "1"
// src/lib.rs
use code_native::*;

#[no_mangle]
pub extern "C" fn code_module_abi_version() -> u32 {
    CODE_ABI_VERSION
}

#[no_mangle]
pub unsafe extern "C" fn code_module_dispatch(out: *mut CodeValue, particle: *const CodeValue) {
    let particle = &*particle;
    match read_field_str(particle, "_class") {
        Some("Double") => {
            let value = read_field_number(particle, "value").unwrap_or(0.0);
            make_result(&mut *out, c"DoubleResult", |slot| number(slot, value * 2.0));
        }
        // A class this module does not handle answers null — see
        // docs/todo/errors-as-particles.md.
        _ => null(out),
    }
}
cargo build --release
# target/release/libmymodule.so
| my_script.code
link "libmymodule.so" as m
emit Double { _class = "Double", value = 21 } to m get result
assert result.value = 42

Run it with code run my_script.code, or code build my_script.code to compile a native binary that dlopen's the module at startup — both output modes accept a .so the same way.

Reading a value

A CodeValue is its tag, heap and one payload the tag decides (ABI version 2, 16 bytes). Read it through the helpers — read_str, read_number, find_field, array_elems, object_entries — or the methods: number(), str_ptr(), boolean(), and for an array or object len(), items() (for slot_at) and keys(). Each answers 0 or null for a value of another kind. An array's or object's count and keys live in its block, not in the value.

Why only two required exports

code_module_abi_version and code_module_dispatch are it. There's no code_module! macro generating boilerplate here (unlike the old language's own code-native, which generated a whole descriptor table of handlers/vars/types/emissions): the new ABI dropped that design for one function a module dispatches through itself — see docs/todo/native-module-linking.md in the main repo for why. code_release needs no code from you at all — it comes from the runtime.c this crate links in automatically.

Exported variables

Optional third export, code_module_vars, is what makes m.someConst work alongside emit ... to m:

use code_native::*;
use std::sync::OnceLock;

static VARS: OnceLock<CodeVarList> = OnceLock::new();

#[no_mangle]
pub extern "C" fn code_module_vars() -> *const CodeVarList {
    VARS.get_or_init(|| {
        let mut buf = SlotBuffer::new(1);
        number(buf.slot_mut(0), 3.14159);
        let values = buf.slot_mut(0) as *mut CodeValue;
        // Both leaked deliberately: the ABI requires this data to stay
        // valid for the module's whole lifetime, the same requirement a C
        // module meets with `static` storage.
        std::mem::forget(buf);
        let names: &'static [*const std::ffi::c_char] = Box::leak(Box::new([c"pi".as_ptr()]));
        CodeVarList { count: 1, names: names.as_ptr(), values }
    })
}

See code_abi.h's own doc comment (vendored into this crate at vendor/code_abi.h) for the full CodeVarList contract — this crate mirrors it field-for-field rather than hiding it, since building the 'static-lifetime buffer correctly is easier to get right by following the same shape a C module uses than behind a leaky abstraction.

Failing without ending the program

A module may never bring the application down. Report a failure by returning an Exception — the program receives it as an ordinary value, tests it with ∈ Exception, and may read message or ignore it entirely:

exception(out, "mymodule", "cannot open the door");

code_runtime_error is deprecated for module use and will leave this crate once the C runtime has an error channel of its own.

guarded — and why it cannot live in the host

Wrap your dispatch in guarded so a panic becomes an Exception too:

#[no_mangle]
pub unsafe extern "C" fn code_module_dispatch(
    out: *mut CodeValue,
    particle: *const CodeValue,
) {
    let particle = &*particle;
    guarded(&mut *out, "mymodule", |out| {
        match read_field_str(particle, "_class").unwrap_or("") {
            "Double" => { /* ... */ }
            _ => null(out),
        }
    })
}

This is not something the host could do for you. A panic escaping an extern "C" function aborts the process rather than unwinding, so the host's own catch_unwind never runs — the catch has to happen on this side of the FFI boundary. tests/native_modules/test_panics exists to keep that true.

It covers what "written wrong" usually means: unwrap/expect, index and slice bounds, arithmetic overflow, explicit panic!/assert!, and panics from inside dependencies. It cannot cover a deliberate exit, an infinite loop, or undefined behaviour reached through unsafe.

This is why Rust is the recommended path for a third-party module. In C the same guarantee does not exist: a module that forgets a NULL check segfaults, and an integer 100 / 0 raises SIGFPE — neither is catchable by anything, in any language, from anywhere. (Rust will not even compile the latter.) The C path stays as the ABI's reference implementation; production modules should take this one.

Speaking first (inbound emissions)

A module normally only answers an emit. To push particles into the program on its own initiative — an event source, or a module reporting what went wrong — take the optional code_module_set_inbound export and push:

use code_native::*;

// Generates `code_module_set_inbound`. A macro rather than a function in
// this crate on purpose: a `#[no_mangle]` symbol defined in a *dependency*
// is not reliably kept in the final cdylib, so the export has to be emitted
// in your crate.
code_native::declare_inbound!();

fn report(message: &str) {
    let mut particle = CodeValue::zeroed();
    let mut buf = SlotBuffer::new(3);
    borrowed_str(buf.slot_mut(0), c"Exception");
    borrowed_str(buf.slot_mut(1), c"mymodule");
    owned_str(buf.slot_mut(2), message);
    object(&mut particle, &[c"_class", c"source", c"message"], &mut buf);
    buf.release_all();
    emit_inbound(&particle);
    release(&mut particle);
}

Pushed particles reach the program's handlers (not the module's own), dispatched between top-level statements. Two things worth knowing:

  • emit_inbound returns false when the host never took an inbound channel. Pushing is always best-effort, and a module has to stay correct when nobody is listening.
  • A pushed class the program has no handler for is dropped. That is what lets a module report something without every program that links it having to handle it. Since 2026-08-28 the outbound direction agrees: emit ... to <anything> with no matching handler is null, not an error.

The queue is bounded at CODE_INBOUND_CAPACITY (256) per module, dropping the oldest, so a module that outruns the program costs bounded memory.

.a static modules

Everything above is the .so path — code_abi.h's "primary format", the one artifact both code run and code build accept. A .a uses a different, simpler contract: it links straight into the host binary, so there is no deep-copy boundary, no per-module code_release, and exactly one runtime — the host's. In exchange it needs a symbol prefix, since every .a linked into one program shares a flat symbol table.

Two changes to your Cargo.toml:

[lib]
crate-type = ["staticlib"]

[dependencies]
code-native = { version = "1", default-features = false, features = ["static-module"] }

static-module is what makes this work: without it this crate compiles the vendored runtime.c into your archive, and linking that against a host that already has one is multiple definition of 'code_release' — forty-one symbols over. With it, the crate brings no runtime and calls the host's.

Then prefix your exports with a name unique among every .a the program will link alongside:

#[no_mangle]
pub extern "C" fn mymath_code_module_abi_version() -> u32 { CODE_ABI_VERSION }

#[no_mangle]
pub unsafe extern "C" fn mymath_code_module_dispatch(
    out: *mut CodeValue,
    particle: *const CodeValue,
) { /* ... */ }

Nothing in the language names the prefix — code build finds it by running nm on the archive, so it only has to be unique. code_module_vars takes the prefix too if you export it. A working example is tests/native_modules/test_math_static/ in the main repo.

code run refuses a .a outright (there is no dlopen for an archive), so fixtures that link one are buildonly_*.

Safety

Most of this crate's surface is safe Rust, but code_module_dispatch itself is necessarily unsafe extern "C" fn — it's called across an FFI boundary with a raw pointer the host guarantees is valid, which Rust has no way to express short of unsafe. Everything you do inside the handler (read_field_str, number, make_result, …) is safe.

code-native on crates.io already has 0.2.0 published under this same repo + account, from the old language — a completely different API (the macro/descriptor-table design this README's "Why only two required exports" section explains is gone) and a different license (that one's MIT; this one is GPL-3.0, since it links runtime.c from the main repo directly rather than reimplementing it). That's why this package starts at 1.0.0 rather than continuing the 0.2.x line: a real break — API and license both — deserves a major version, not one a ^0.2 pin would silently accept. (Same call crates/code-wasm made for its own npm package, for the same reason.)

Releasing (maintainers)

Published via crates.io Trusted Publishing (OIDC) from GitHub Actions (.github/workflows/publish-crates-native.yml) — no CARGO_REGISTRY_TOKEN stored anywhere, mirroring crates/code-wasm's npm Trusted Publishing setup.

One-time setup (crates.io account configuration, can't be done from CI) — normally the very first publish has to be manual, since crates.io has no equivalent to npm's Staged Packages for configuring a trusted publisher before a crate exists at all. Not needed here: the crate already exists (see above), owned by the same account doing this setup, so go straight to its Settings → Trusted Publishing page and add:

  • Repository owner: codelovesme
  • Repository name: code
  • Workflow filename: publish-crates-native.yml
  • Environment: leave blank

Every release after that is the repository's own release tag — there is no separate one. Everything published from this repo shares one version, so code v1.1.0, code-native 1.1.0 and every module at 1.1.0 ship together and a consumer never has to check whether they match (tests/one_version.rs holds every manifest to it):

git tag v1.1.0   # whatever the new version is
git push origin v1.1.0

The workflow sets Cargo.toml's version from the tag itself, verifies the package builds standalone (cargo publish --dry-run), and publishes. workflow_dispatch (the "Run workflow" button in the Actions tab) does everything except the actual publish — a real dry run against the exact package that would ship.

Optional borrowed metadata dispatch (unreleased)

A module may additionally export code_module_dispatch_borrowed_v1(out, api, particle) -> c_int. The interpreter tries it before constructing the full ABI value tree. Return nonzero for a module-owned result, or zero to request ordinary dispatch. The host releases either result, including one declined. Keep the required ordinary exports: older hosts ignore this entry point. The value layout and CODE_ABI_VERSION remain 2; the metadata API version is 1.

borrowed::BorrowedValue::from_raw checks the API version and exposes metadata, first-field lookup and UTF-8 text. It does not expose array elements or scalar number payloads. Use it only for handlers that can answer from that information (arrays/strings Length are examples); decline everything else. See arrays for an implementation.

All opaque pointers, field values and text slices are immutable, confined to the calling thread, and borrowed only until dispatch returns. Never retain them or pass them to CodeValue constructors/releases. Return an ordinary owned CodeValue. The host's existing transport limitations still apply. Wire types are mirrored in vendor/borrowed_abi.rs and checked against the main crate.

License

GPL-3.0 — see LICENSE. This crate links runtime.c from the main code repo directly (vendored, not reimplemented), so it carries the same license.