This guide is for existing Rustler crates whose domain implementation remains in
Rust. For a new ordinary NIF, start with
RustQ.Native and defnif instead; they derive the crate,
wrapper, codecs, stubs, initialization, build, and loading together.
Use the helpers below when an existing crate intentionally keeps Cargo and runtime ownership but wants structural generation for repetitive Rustler glue. For generator architecture and checked output, also read Designing RustQ Generators and Generating Rust.
Keep one signature source
A handwritten crate can keep domain implementation functions with an _impl
suffix:
fn parse_nif_impl<'a>(env: Env<'a>, source: &str) -> NifResult<Term<'a>> {
// domain implementation
}Declare only export policy in Elixir:
nifs = [
parse_nif: [],
compile_nif: [attrs: [A.attr(:allow, [A.path([:clippy, :too_many_arguments])])]]
]
rust "native/my_nif/src/generated_nifs.rs" do
RustQ.Rustler.Nif.wrappers_from_source(
"native/my_nif/src/lib.rs",
nifs,
schedule: :dirty_cpu
)
end
generate "lib/my_app/native/generated_stubs.ex" do
content(
RustQ.Rustler.Nif.stubs_from_source(
"native/my_nif/src/lib.rs",
nifs,
MyApp.Native.GeneratedStubs
)
)
endRustQ.Syn reads arguments, returns, lifetimes, and Rustler's injected Env
from the Rust source. The same metadata drives Rust wrappers and Elixir stub
arities, so the export manifest does not repeat signatures.
Keep human-facing @specs in the runtime module and install the generated stubs
once:
defmodule MyApp.Native do
@spec parse_nif(String.t()) :: {:ok, map()} | {:error, term()}
use MyApp.Native.GeneratedStubs
endIf the implementation itself can be expressed cleanly as Rusty-Elixir, prefer
defnif rather than retaining an _impl wrapper merely for appearance.
Atom registries
Discover atom calls structurally and emit one registry:
atoms =
"native/my_nif/src/*.rs"
|> Path.wildcard()
|> Enum.flat_map(fn path ->
path |> File.read!() |> RustQ.Syn.atom_references!()
end)
|> Enum.uniq()
|> Enum.sort()
rust "native/my_nif/src/generated_atoms.rs" do
RustQ.Rustler.Atom.declaration(atoms)
endExclude generated files from discovery. When a schema or manifest introduces additional keys, derive those keys from that same source so generation reaches a stable result in one pass. Do not maintain a second handwritten atom registry.
Term encoders
RustQ.Rustler.Term.encoder/2 creates a structural rustler::Encoder
implementation. Simple fields are atoms; {key, field} renames a key:
RustQ.Rustler.Term.encoder(:EncodedLocation,
fields: [:start, {:end_, :end}, :line]
)Lifetime-bearing adapters can declare target lifetimes and structural field transformations:
RustQ.Rustler.Term.encoder(:EncodedError,
target_lifetimes: [:_],
fields: [
:message,
code: [when_some: true, via: :as_str]
]
)Common field operations include:
field: [:result, :code]— nested field pathvia: :as_str— zero-argument method before encodingwith: :encode_value— helper called with the environment and valueborrow: false— pass a helper value without adding&when_some: true— omit the entry when an option isNoneoptional: [wrap: :EncodedValue]— wrapSome, encodeNoneas nilmap: [wrap: :EncodedValue]— wrap collection elementsmap: [convert: :EncodedValue]— map throughFromfallback: [...]— generate a structural fallback chain
These options represent typed operations. If a transformation becomes domain logic, move it to a named Rust or Rusty-Elixir helper rather than embedding raw expressions in field metadata.
Encoders for types from Rust source
Types owned by another crate cannot implement rustler::Encoder in yours, and
may not implement serde::Serialize either. Term.encoders_from_source/3 reads
their definitions through RustQ.Syn and builds one encoder function per
reachable type, so the Rust source stays the only owner of fields and variants:
index = RustQ.Syn.Index.cached_package("my_ir", manifest_path: "native/my_nif/Cargo.toml")
roots = ["BlockIRNode", "OperationNode"]
opts = [
tag: :kind,
wrappers: [sequence: [:ArenaVec], pointer: [:ArenaBox]],
external: [SimpleExpressionNode: :encode_simple_expr],
types: [SetPropIRNode: [except: [:loc]]]
]
rust "native/my_nif/src/generated_ir_encoders.rs" do
[
RustQ.Rustler.Atom.declaration(Term.encoder_atoms_from_source(index, roots, opts)),
Term.encoders_from_source(index, roots, opts)
]
endEach function has the form
fn encode_set_prop_ir_node<'a>(env: Env<'a>, value: &SetPropIRNode<'_>) -> Term<'a>.
Structs encode as atom-keyed maps and newtype structs as their field. Unit
variants encode as atoms, tuple variants as their payload, and named variants as
maps. With :tag, data-carrying variants also carry the variant atom under that
key. Option encodes None as nil, sequences and sets as lists, and maps as
maps.
The traversal is closed. Every reachable type must be indexed, a wrapper, a
scalar, or mapped in :external; otherwise generation fails and lists the
unmapped types. An upstream change therefore appears as a generation error or a
rustq.gen --check diff, not as handwritten Rust to update. Names defined in
several sources, generic types, and a :tag that would replace a payload field
with the same key are reported the same way; rename the field with :fields.
Tags, key and variant renames, excluded fields, transparent: true structs
that encode as their only field, and external helpers are explicit policy. Keep them in the consumer's generator, not in RustQ.
Resources, options, and schemas
RustQ.Rustler.Resource, RustQ.Rustler.Opts, and
RustQ.Rustler.Schema provide structural generation for larger existing
surfaces. Use them when a schema is already the source of truth. Do not add a
schema DSL solely to avoid writing a small, clear @type used by
RustQ.Native.
Prefer one structural owner for fields and variants. Rust declarations, term codecs, atom keys, and dispatchers should be projections of that owner rather than parallel tables.
CI
Check generated output and both language toolchains:
ci: [
"rustq.gen --check",
"compile --warnings-as-errors",
"test"
]Also run cargo fmt --check, cargo check, and cargo clippy -- -D warnings
for every generated native crate. Generated-source freshness alone does not
prove that imported Rust metadata and wrappers still compile together.