rebar3_otter: rebar3 Plugin
Purpose
rebar3_otter is a rebar3 plugin that integrates Rust NIF compilation into the Erlang build pipeline. It invokes cargo, locates the built shared library, and places it where erlang:load_nif/2 expects to find it.
This is a pure Erlang OTP application. It has no Rust dependency — it treats the Rust toolchain as an external tool, the same way rebar3 treats the Erlang compiler.
What it does not do
- It does not generate Erlang boilerplate. NIF loading is standard Erlang and belongs in the user's module.
- It does not manage Rust toolchain installation.
cargomust already be onPATH.
rebar3 Integration
The plugin registers two providers in the default namespace; users wire them up via provider_hooks:
| Provider name | Module | Hooked as |
|---|---|---|
otter_compile | rebar3_otter__compile | {pre, [{compile, otter_compile}]} |
otter_clean | rebar3_otter__clean | {pre, [{clean, otter_clean}]} |
A third provider lives in the otter namespace and is invoked directly:
| Provider name | Module | Invoked as |
|---|---|---|
new | rebar3_otter__new | rebar3 otter new --name my_nif |
Configuration
In rebar.config:
{plugins, [rebar3_otter]}.
{otter_crates, [
#{name => "my_crate", path => "native/my_crate"}
]}.Each entry in otter_crates is a map describing one crate:
| Key | Required? | Type | Description |
|---|---|---|---|
name | Required | string() | binary() | Cargo crate name — must match [package].name in the crate's Cargo.toml. |
path | Required | string() | binary() | Crate directory, relative to the declaring app's directory. |
mode | Optional | release | debug | Cargo build profile. Defaults to release. |
features | Optional | [string() | binary()] | Cargo features to enable. Defaults to []. |
target | Optional | string() | binary() | undefined | Cross-compilation target triple; undefined builds for the host. Defaults to undefined. |
String-typed values (name, path, target, and each features element)
accept a string or binary, normalized to a string via to_str/1. They mirror
Cargo's own strings and carry hyphens naturally.
Multiple crates are supported — each entry in otter_crates is compiled independently.
otter_crates is read per application. Declare it in each app's own
rebar.config; path is resolved relative to that app's directory and the
built artifact is installed into the same app's priv/native/. In a single-app
project the app dir is the project root, so nothing special is needed. In an
umbrella project each app declares the crates it owns, and the .so lands where
code:priv_dir(App) for that app resolves — a top-level otter_crates in an
umbrella with no root app is not attached to any application and is ignored.
Compile Provider (otter_compile, module rebar3_otter__compile)
Runs as a pre_compile hook so the .so is in place before the Erlang compiler runs (which may check for NIF existence).
Steps
Read and validate config — iterate
rebar_state:project_apps/1; for each app read its ownotter_crates(rebar_app_info:get/3) and pass it throughrebar3_otter__config:validate/1, which checks required fields (name,path), normalizes optional fields (mode,features,target), rejects unknown keys, and produces a list of normalized crate maps. The app's directory (rebar_app_info:dir/1) is the base for both the cratepathand the install location. Validation errors halt the build with a formatted message viarebar_api:abort/2(the rebar3 pre-hook layer mangles{error, _}return values, so config errors take the abort path instead).Invoke cargo:
cargo build \ --manifest-path <path>/Cargo.toml \ --target-dir <path>/target \ -p <name> \ [--release] \ [--features feat1,feat2] \ [--target <triple>]Cargo runs with
ERTS_INCLUDE_DIRset to the running ERTS's include dir (<root>/erts-<vsn>/include), so the native build (e.g. abindgen/ccstep, orenif-ffi) can locateerl_nif.hwithout the user configuring a path. Plaincargo build(the default human message format) renders compiler diagnostics to stderr;run/2lets the child's stderr through to the terminal, so errors and warnings appear in therebar3output directly.--target-diris pinned to<crate>/targetso the output location is dictated rather than discovered (see step 3). Cargo is invoked unconditionally — its own incremental check decides whether real work needs to happen, and no-ops cost ~50–200ms.Compute artifact path (by convention) — because the target dir is pinned and cdylib final artifacts are not content-hashed, the output path is fully determined by the inputs:
<target_dir>/[<triple>/]<release|debug>/<file>, where<file>islib<name>.so(Linux),lib<name>.dylib(macOS), or<name>.dll(Windows), with<name>normalized-→_as cargo does for lib targets. Thelibprefix / extension follow the target platform — derived from the--targettriple when set (so cross-compiles resolve), otherwise the build host (os:type/0). This deliberately avoids parsing cargo's JSON output, which would pull in the OTP-27-only stdlibjsonmodule; pinning--target-diris what makes the path a guarantee instead of a guess (it removes the workspace / custom-target-dirambiguity the JSON scrape previously absorbed). The computed path is confirmed to exist (filelib:is_file/1); a miss yields the{no_cdylib, _}error below.Determine output filename — the destination uses the platform-appropriate extension Erlang expects:
- Linux:
<name>.so - macOS:
<name>.so(not.dylib— Erlang expects.soregardless) - Windows:
<name>.dll
- Linux:
Copy artifact to the owning app's
priv/native/<name>.so. Createpriv/native/if it does not exist.Surface diagnostics — cargo emits compiler errors and warnings on stderr (inherited from the child process), so they appear in the
rebar3build output directly without us needing to parse them.
Error handling
cargonot on PATH → clear error message, build fails- Cargo compilation failure → surface the compiler errors, build fails
- No
cdylibartifact found at the computed path → error indicating the crate may not havecrate-type = ["cdylib"]in itsCargo.toml - Artifact copy into
priv/native/failed → error with the underlying file reason (copy_failed)
Clean Provider (otter_clean, module rebar3_otter__clean)
Runs as a pre_clean hook.
- For each project app's configured crates, remove the app's
priv/native/<name>.soif it exists. - Remove the crate's pinned target directory (
<crate>/target) directly. Since the build dictates that directory via--target-dir, cleaning is an exactfile:del_dir_r/1— no cargo invocation, so it works even without a toolchain installed and cannot over-clean a shared workspace target dir.
New Provider (otter new, module rebar3_otter__new)
rebar3 otter new --name my_nif
Scaffolds a minimal NIF crate:
native/my_nif/Cargo.toml:
[package]
name = "my_nif"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
otter-nif = "0.3"native/my_nif/src/lib.rs:
use otter::types::{AnyTerm, Atom, CallEnv, InitEnv};
// Optional load hook. Atoms listed in `init!` are interned by the
// scaffolding before this runs, so a fresh crate has nothing to do here.
fn on_load(_env: InitEnv, _load_info: AnyTerm) -> bool {
true
}
#[otter::nif]
fn hello(_env: CallEnv) -> Atom {
otter::atom![world]
}
otter::init!("my_nif", [hello], atoms = [world], load = on_load);Note: The scaffolded Erlang module and -on_load declaration are intentionally not generated. NIF loading is two lines of standard Erlang that the programmer should write and understand:
-on_load(init/0).
init() -> erlang:load_nif(filename:join(code:priv_dir(my_app), "native/my_nif"), 0).NIF Loading (user's responsibility)
The plugin does not generate or modify Erlang source files. The user writes their own NIF loading boilerplate:
-module(my_module).
-on_load(init/0).
init() ->
erlang:load_nif(filename:join(code:priv_dir(my_app), "native/my_nif"), 0).
%% Stub replaced at load time by the NIF implementation
my_function(_Arg) -> exit(nif_not_loaded).This is standard Erlang. Every Erlang programmer who has written a NIF before will recognize it immediately.
Module Structure
rebar3_otter/src/
├── rebar3_otter.erl % plugin entry point, registers providers
├── rebar3_otter__compile.erl % pre_compile provider (otter_compile)
├── rebar3_otter__clean.erl % pre_clean provider (otter_clean)
├── rebar3_otter__new.erl % scaffold provider (otter new)
├── rebar3_otter__cargo.erl % cargo invocation and cdylib artifact resolution
└── rebar3_otter__config.erl % otter_crates schema validationThe double-underscore convention is a local stylistic choice so the underscore-separated namespace inside rebar3_otter is unambiguous against the rebar3 plugin name itself.
Dependency tracking
The compile provider invokes cargo on every run; cargo's own incremental check decides whether real work needs to happen, and no-ops cost ~50–200ms. This is intentionally simple — cargo already tracks every input that affects a build (sources, features, lockfile, target, environment) and the plugin would only re-implement it badly. The plugin's responsibility is "invoke cargo, then if cargo succeeded, install the artifact." Nothing more.