Expand description
§Overview
Write fast and efficient Erlang NIFs in Rust
otter is built from the ground up to give Erlang and Elixir programmers an
easy, efficient way to write NIFs in Rust. The design is driven by two
foundational principles:
- Functionality —
otter’s first priority is an API surface that faithfully captures the capabilities of the underlyingerl_nifC API — and where the safe surface isn’t enough, the raw API can be exposed by enabling therawfeature. - Speed — One of the prime motivations for writing NIFs is to achieve
speed and memory efficiency not possible in the Erlang VM.
otter’s datatypes are designed to “pay only for what you need” through controlled layering ofenif_*calls, with extensive work to employ compile-time constraints rather than runtime ones.
§Quickstart
use otter::types::CallEnv;
// Declare the module: name, NIF table, and any atoms to pre-intern.
otter::init!("my_nif", [add], atoms = [ok, error]);
// A NIF that adds two integers, decoded straight to `i64` and encoded back.
#[otter::nif]
fn add(_env: CallEnv<'_>, a: i64, b: i64) -> i64 {
a + b
}§Features
A few highlights of the surface; the guided tour below maps the whole crate.
§Generative env/term lifetimes
Each term is branded by the env that produced it. Env<'id> is
a sealed trait whose lifetime 'id is a generative brand — a fresh,
non-escaping marker minted at every entry point. Every term type carries that
brand (Integer<'id>, Binary<'id>, …), so a term from one env cannot be
used with another: the compiler rejects it, with no per-operation runtime
check. The BEAM treats a cross-env term as undefined behavior, and this
construction makes that mistake impossible to write. (This is the
generativity pattern.)
§Layered term resolution
Reading a term through the C API is a cascade: enif_term_type and the
enif_is_* family reveal a type, enif_get_tuple hands back elements that
are themselves opaque terms, and so on — a full decode can cost many calls
into the VM, most of them unnecessary. otter mirrors that progression in its
types, so you stop at exactly the depth you need: AnyTerm
→ TypedTerm → a concrete term type → a native Rust
value.
Resolution is lazy. Constructing a term is free — one machine word plus a zero-sized brand marker — and nothing is read off the BEAM heap until an env-passing accessor asks for it:
// Constructors are associated functions taking an env:
let i = Integer::from_i64(env, 42);
let b = Binary::from_bytes(env, b"hello");
let m = Map::new(env);
// Accessors take an env:
let n = i.to_i64(env); // Option<i64>
let bytes = b.as_bytes(env); // &[u8], zero-copy into the BEAM heap
// Term inputs are taken as `impl Term<'id>` — pass concrete types directly,
// no `.encode()` needed; a term of the wrong brand fails to compile:
let m = m.put(env, key_atom, i);§Honest codecs
Encoder/Decoder move between terms and
native Rust types — integers, floats, bool, str/String, tuples,
Vec<T>, HashMap<K, V>, and (with the bigint feature) arbitrary-precision
integers. A decoder never lies: 300 into a u8 is an IntegerOverflow
error, not a silently truncated 44, and a float will not quietly swallow an
integer. A failed decode on a NIF argument becomes badarg; a failed encode
on a return becomes badret.
§Faithful Erlang types
All eleven term types are present, and they mirror Erlang rather than a
convenient subset of it. List is a real cons cell
(Nil | Cell(head, tail)), so improper lists are first-class and a codec
rejects a bad tail with a clean error instead of papering over it.
§The full send matrix
Message passing is a 2×2 of four free verbs: send_copy/send_move — copy a
live term, or steal an OwnedEnvArena heap in O(1) —
each available plain (NULL caller, off-thread) or _from (caller-attributed,
in-NIF). You can build a message on a worker thread with no env in hand and
move its whole heap into a process in a single send.
§Hot code upgrade without cross-build ABI assumptions
Every otter module is hot-code-upgradeable. Erlang upgrades a running system
in place: a second build of a NIF library can load beside the first and
inherit its live state (resources, priv_data). The two builds may come from
different compilers and allocators and need not be byte-identical source, so
the upgrade boundary is a foreign-ABI boundary.
Outside the raw feature, otter never assumes across that boundary that
allocators or drop glue are compatible, that std datatypes share a layout, or
that identical source compiles to a compatible layout; the safe path holds by
construction. Code that relies on cross-build ABI compatibility belongs only
behind raw, where the caller takes responsibility. The full treatment is in
the project’s docs/UPGRADE.md.
§The raw escape hatch
Where the safe surface isn’t enough, the raw feature exposes the
all-unsafe enif_ffi crate directly, widens the brand-bridging term
constructors to pub, and accepts the _raw lifecycle callbacks in
init! — a deliberate, opt-in boundary where you take
responsibility for the ABI. See Cargo features.
§A guided tour
The crate is organized into a few logical areas:
- The env/term spine —
typesdefines theEnvtrait and its concrete kinds (CallEnv,InitEnv,CallbackEnv,DeinitEnv,OwnedEnv), theTermtrait and its resolution levels, theOwnedEnvArena, the four message send verbs, and theRaisedexception witness. - Term types —
types::termshas one concrete type per Erlang type:Atom,Integer,Float,Binary/Bitstring,List,Tuple,Map,Pid,Port,Reference, andFun. - Owned buffers —
BinaryBuf, a growable byte buffer you fill off-thread with no env in hand, then hand to the BEAM in one move. - Codecs —
codecholds theEncoder/Decodertraits and their impls for the native Rust types. - Resources and per-build state —
resourcegivesResourceArc<T>, a refcounted handle to a Rust value the BEAM can hold and hand back;priv_datais the per-build private-data slot and the resource-type registry. - Runtime services —
time(BEAM monotonic clock),system(thread-type introspection),select(I/O event multiplexing), andalloc(route Rust’s global allocator through the BEAM allocator). - Macros —
nif!marks a function as a NIF,init!declares the module entry point, andatom!retrieves a pre-interned atom.
§Cargo features
All features are off by default; otter always binds NIF 2.17 (OTP 26).
| Feature | Effect |
|---|---|
nif_2_18 | Enable the NIF 2.18 (OTP 29) additions. |
bigint | Pull in the optional num-bigint dependency (compiled only when this feature is enabled) and add arbitrary-precision integers: types::BigInt (a re-export of num_bigint::BigInt), its Encoder/Decoder, and Integer::to_bigint/from_bigint. |
raw | Expose the raw, all-unsafe enif_ffi crate as the escape hatch, widen the brand-bridging term constructors to pub, and accept the _raw lifecycle callbacks in init!. |
Re-exports§
pub use enif_ffi;raw
Modules§
- alloc
- An opt-in
#[global_allocator]backed by the BEAM allocator. - codec
- Conversions between Erlang terms and native Rust types —
Encoder,Decoder, andCodecError. - priv_
data PrivData— the per-library private data the BEAM hands back viaenif_priv_data.- resource
- Resource types:
Resourcetrait,ResourceArc<T>,Monitor. - select
- I/O event multiplexing.
- system
- BEAM system information and thread introspection.
- time
- BEAM time functions.
- types
Macros§
- atom
- Retrieve an atom pre-declared in the
atoms = [...]list ofinit!. - enif_
global_ allocator - Install
EnifAllocas the#[global_allocator]of the current crate. - init
- Declare the NIF library: its module name, exported NIFs, pre-interned atoms, resource types, and lifecycle callbacks. Invoke it exactly once per library.
Attribute Macros§
- nif
- Mark a Rust function as a NIF.
- resource_
impl - Optional marker attribute for an
impl Resource for Tblock.