RustQ makes Elixir↔Rust bridge generation readable. The goal is not to move Rust
string concatenation from .rs files into .ex files; it is to let Elixir act
as a typed, semantic authoring and metaprogramming layer for Rust.
This is the canonical best-practices guide. Task-specific mechanics live in:
RustQ also ships SKILL.md, the operational version of this
guide for coding agents.
The authoring ladder
Before writing generated Rust as text, use the highest honest layer:
defniffor a public NIF implemented as Rusty-Elixir.defrustordefrustpfor generated Rust helpers.- Ordinary Elixir functions, macros,
quote, andunquotefor composition. - Rust source, Cargo package, typespec, and schema metadata for inference.
- RustQ AST for Rust-only structure.
- Parseable
.rstemplates for substantial handwritten Rust around generated regions. - Tiny raw token escapes only when RustQ has no semantic representation.
Do not skip directly from Elixir to strings. A missing AST node or lowering form is usually a RustQ capability gap, not a reason to create a private string DSL.
Start with the owner of the native crate
Use RustQ.Native when RustQ should generate, build, and load the crate:
defmodule MyApp.Native do
use RustQ.Native
@spec sum([float()]) :: float()
defnif sum(values), do: Enum.sum(values)
endUse RustQ.Meta when RustQ generates helpers or items for a crate owned
elsewhere:
defmodule MyApp.Generated do
use RustQ.Meta,
rust_sources: ["native/my_app/src/helpers.rs"]
alias RustQ.Type, as: R
@spec decode(R.term()) :: R.nif_result(Value.t())
defrust decode(term), do: decode_value(term)
endDo not make RustQ seize Cargo, loading, initialization, or release ownership from
an existing or precompiled crate. Use RustQ.Native, build: false, load: false
when that crate only needs ABI-prepared items.
Specs are the source of truth
Write a real @spec for every defnif and defrust. RustQ uses it for the Rust
signature, expected return type, propagation, borrowing, and NIF boundary.
Prefer ordinary Elixir and remote types:
@spec draw(
R.ref(SkiaSafe.Canvas.t()),
GeneratedOpts.CircleOpts.t(R.lifetime(:a)),
R.slice({R.atom(), R.term()})
) :: R.nif_result(R.unit())Use RustQ.Type only for Rust-specific precision such as references, fixed-width
numbers, lifetimes, NifResult, slices, options, results, vectors, terms, and
resources. R.raw/1 and R.path/1,2 are low-level escapes, not the default way
to spell an external Rust type. A function that returns a borrow from one of
several references names the lifetime: R.ref(Record.t(), R.lifetime(:a)) is
&'a Record, and R.slice(type, R.lifetime(:a)) is &'a [T].
Use ordinary @type declarations for RustQ-owned shapes. Maps and Elixir
structs can derive Rust structs and directional codecs; atom unions can derive
unit enums; unions of structural types can derive tagged enums. Use @typep
for shapes that never cross the NIF boundary; see
Model data as Elixir data.
Let RustQ infer
RustQ can infer ?, &, and &mut when it knows callable signatures and
expected types:
@spec decode_color(R.term()) :: R.nif_result(Color.t())
defrust decode_color(term) do
value = decode_as!(term, R.u32())
{:ok, Color.from_argb(255, 0, 0, value)}
end
@spec draw(R.ref(Canvas.t()), R.term()) :: R.nif_result(R.unit())
defrust draw(canvas, term) do
canvas.draw_color(decode_color(term))
:ok
endBefore adding unwrap!, ref, mut_ref, or a wrapper function, ask whether
RustQ can see the called function or method. Callable metadata can come from:
- local
@specs - RustQ modules named through
alias, andcallable_modules - configured
rust_sources - configured
rust_packages - known receiver and expected argument types
Use unwrap! only to force propagation when the metadata cannot yet express the
shape. Use ok_or! for an intentional Option<T> to Result/NifResult
boundary. Explicit borrowing helpers remain useful when the borrow itself is
part of the intended semantics.
Read Rust instead of shadowing it
If Rust owns a function, method, or type, import its metadata rather than maintaining a parallel Elixir registry:
use RustQ.Meta,
rust_sources: ["native/my_app/src/helpers.rs"],
rust_packages: [{"skia-safe", manifest_path: "native/my_app/Cargo.toml"}],
callable_modules: [MyApp.GeneratedEnums]This is especially important for fallible methods and generic adapters such as
impl AsRef<T>, impl Into<T>, and impl IntoIterator<Item = T>. A trivial
wrapper created only because callable metadata is missing is technical debt.
Expose the real Rust definition or improve RustQ's inference instead.
Share Rust through modules
rust_sources reads Rust a module calls but does not own. Rust a module emits
as it is, such as a runtime a library's generated code includes, is what the
module provides. Ship it in templates/ beside the module, named as the
module's file is:
lib/my_lib/runtime.ex # defmodule MyLib.Runtime do use RustQ.Meta end
lib/my_lib/templates/runtime.rsprovides: ["other.rs"] names other files, relative to the module's file, and
provides: [] none. Mix recompiles the module when its Rust file changes,
appears, or goes.
Its functions and methods join the module's own callables, and each Rust type
without lifetimes or type parameters becomes an Elixir type: Record is
record(). __rustq_items__/0 and __rustq_source__/0 emit the Rust.
Other modules use it as Elixir modules use each other, by naming it:
alias MyLib.Runtime
@spec first(R.slice(R.u8())) :: R.nif_result(R.option(R.u32()))
defrust first(bytes) do
record = Runtime.decode_record(bytes)
{:ok, record.get(0)}
endA RustQ module named through alias in a spec, a type, or a defrust body is
imported as callable_modules would import it, and Runtime.decode_record(...)
is the Rust call decode_record(...). Neither side knows where the Rust file
is. Other aliases stay Rust paths: SkiaSafe.Canvas.t() is still
skia_safe::Canvas. callable_modules remains for modules a body calls
without naming them.
The Rust modules a module's defrustmods map come with it, under the alias
naming it. A module maps itself with defrustmod __MODULE__:
defmodule MyLib.Runtime do
use RustQ.Meta
defrustmod __MODULE__, as: [:my_lib_records]
defrustmod Limits, as: [:my_lib_limits]
endThen Runtime.Record.FIRST is my_lib_records::Record::FIRST and
Runtime.Limits.MAX is my_lib_limits::MAX in a module that aliases it.
Keep Rusty-Elixir functional
Write implementation logic as valid Elixir-shaped control flow:
- pattern matching and multiple clauses
- recursion for small state machines
case,if,unless,cond, andwith- comprehensions and reducers for repeated work
- ordinary local and remote calls
Prefer recursion over low-level loops:
@spec skip_many(R.mut_ref(Decoder.t()), R.u32()) :: R.nif_result(R.unit())
defrust skip_many(_decoder, 0), do: :ok
defrust skip_many(decoder, remaining) do
skip_one(decoder)
skip_many(decoder, remaining - 1)
endRustQ has internal representations for loop, break, continue, and early
return because its Rust backend needs them. They are not the preferred product
language. Use return! only when a low-level early exit is genuinely clearer.
RustQ deliberately rejects Elixir operations whose semantics cannot be preserved. Do not replace a rejected grapheme, dynamic map, range, process, IO, or protocol operation with a vaguely similar Rust method. Put the semantic choice behind an explicit adapter.
Model data as Elixir data
Declare the shapes a module works with as types, and build and match them the
way Elixir does. @typep declares a Rust-only type: the struct or enum without
the term decoder a @type gets, so it may hold values that have no term codec.
An R.enum variant is a tagged tuple, or a bare atom when it has no payload:
@typep size :: %{required(:width) => float(), required(:height) => float()}
@typep shape :: R.enum(circle: [float()], rect: [size()], empty: [])
@spec area(R.ref(shape())) :: float()
defrust area({:circle, r}), do: 3.14159 * r * r
defrust area({:rect, size}), do: size.width * size.height
defrust area(:empty), do: 0.0pub enum Shape {
Circle(f64),
Rect(Size),
Empty,
}
fn area(arg1: &Shape) -> f64 {
match arg1 {
Shape::Circle(r) => 3.14159 * r * r,
Shape::Rect(size) => size.width * size.height,
Shape::Empty => 0.0,
}
}Tagged tuples construct variants too, wherever the enum is expected:
{:circle, 1.0} is Shape::Circle(1.0). Wrap a declared type in
R.hashable/1 to also derive PartialEq, Eq, and Hash so it can key a map
or set: @typep id :: R.hashable(R.enum(source: [R.u32()], clone: [path()])).
Within a pattern, a constant such as Kind.PLAIN matches the Rust constant
reached through the module's Rust module aliases.
Update a struct with Elixir's update syntax, and let comprehensions, captures,
and container[key] say what they mean:
@typep item :: %{
required(:name) => String.t(),
required(:shape) => shape(),
required(:count) => integer()
}
@spec restock(item(), integer()) :: item()
defrust restock(item, extra), do: %{item | count: item.count + extra}
@spec in_stock([item()]) :: [String.t()]
defrust in_stock(items), do: for(item <- items, item.count > 0, do: item.name)
@spec counts(R.ref(%{String.t() => integer()}), [String.t()]) :: [integer()]
defrust counts(stock, names), do: for(name <- names, do: stock[name])
@spec doubled([integer()]) :: [integer()]
defrust doubled(values), do: Enum.map(values, &(&1 * 2))fn restock(item: Item, extra: i64) -> Item {
Item {
count: item.count + extra,
..item
}
}
fn counts(stock: &HashMap<String, i64>, names: Vec<String>) -> Vec<i64> {
names.into_iter().map(|name| stock[&name]).collect()
}
fn doubled(values: Vec<i64>) -> Vec<i64> {
values.into_iter().map(|value| value * 2).collect::<Vec<i64>>()
}A comprehension used as a value becomes an iterator pipeline collected into the
expected type, so into: follows the target type, as in
for {key, value} <- pairs, into: HashMap.new(), do: {key, value}. Run for its
effects, it becomes nested for loops. Generators may use patterns that can
fail to match; non-matching items are skipped, as in Elixir.
The items of a typed list keep their type inside a comprehension or an Enum
function's closure, so for item <- items, do: %{item | count: 0} updates each
Item. A closure runs once per item, so a struct it updates from outside the
closure is cloned rather than moved, and a later generator over a list from
outside the comprehension copies its items rather than consuming it. Indexing a
map borrows the key, and %{key => value} in a spec is a HashMap.
Elixir never changes a list, so an Enum function takes an owned list only at
its last use. A list the function's closure, a later expression, or a value
still in use was built from is borrowed instead: Enum.map(records, ...) after
by_id = index(records) iterates records.iter(). Questions such as
Enum.any?/2 borrow regardless, items a filter keeps from a borrowed list are
cloned, and numbers and atoms are copied out. A list reached through a
reference, as a field of a borrowed struct, is always borrowed, and a
comprehension over it, or over an owned list still in use, copies its items. A
new value passed where a reference is expected, as HashSet.new(), is
borrowed.
Keyed collections follow the same rules. On a HashMap (%{key => value} in
a spec) or HashSet (MapSet.t(item)), Map.get, fetch!, has_key?,
put, delete, merge, update/4, keys, values, to_list, and new,
and MapSet.new, put, delete, member?, union, difference,
intersection, subset?, disjoint?, size, and to_list keep Elixir's
meaning: values read out are copies, and an update takes the collection only at
its last use, updating a copy otherwise. Rebinding a collection to its update,
as scores = Map.put(scores, name, 1), changes it in place:
scores.insert(name, 1);.
Enum also lowers into, group_by, with_index, find_index,
find_value, reduce_while, sort_by, max_by, min_by, max, min,
uniq, uniq_by, dedup, split_with, at, slice, take_while,
drop_while, zip_with, chunk_every, join, and map_join. A few follow
Rust where Elixir cannot be kept exactly:
with_indexindexes inusize, and a generator over it enumerates in place:for {child, index} <- Enum.with_index(children)isfor (index, child) in children.iter().enumerate().find_valuetakes a function returningsome(value)ornil.reduce_whilereads{:cont, acc}and{:halt, acc}astry_folddoes.max_by,min_by,max, andminneed an empty fallback,fn -> nil endfor anOption, since Elixir raises on an empty list.- Sort and extremum keys are values, as in Elixir: a key read out of an item is cloned, not borrowed.
at,slice, andchunk_everytake non-negative indexes and counts.
A generator over one value matches it beside the item, so
for record <- records, {:some, id} <- [record_id(record)], do: {id, record}
skips records without an id, the way filter_map does, without making a list
for each.
Types another module declares or provides are available once the module is
named through alias or listed in callable_modules. Write them remotely, as Shapes.shape(), since Elixir checks local types; they keep
their single Rust declaration in the module that declares them.
Compose with ordinary Elixir
Ordinary Elixir metaprogramming is the main composition layer:
defmacro with_saved_canvas(do: body) do
quote do
var!(canvas).save()
unquote(body)
var!(canvas).restore()
end
endRustQ expands normal macros before lowering. Use helper functions returning
quoted Rusty-Elixir, defmacro, quote, unquote, and unquote_splicing
before inventing a RustQ-specific syntax.
defrustmacro solves a narrower problem: it emits a compact Rust
macro_rules! helper when the generated Rust itself would otherwise repeat a
small pattern. Keep it small and keep its body Rusty-Elixir. Do not use it as a
second general-purpose language.
Conditional Rust helpers
Use @cfg before defrust or defrustp to emit a Cargo compile-time
condition. RustQ does not evaluate the predicate during Elixir compilation.
@spec normalize(integer()) :: integer()
@cfg feature: "extended"
defrust normalize(0), do: 0
defrust normalize(value), do: value + 1
@cfg not: [feature: "extended"]
defrust normalize(value), do: valueA condition belongs to the following implementation group. Consecutive clauses
of the same function inherit it; another @cfg begins a separate implementation.
A different function ends that inheritance. Keep the function's shared @spec
outside the groups. Do not mix unconditional and conditional implementations.
Compose predicates with keyword lists, preserving repeated keys:
@cfg all: [feature: "native-test", not: [target_os: "windows"]]
@cfg any: [target_os: "linux", target_os: "macos"]These are alternative examples, not two attributes to stack on one declaration.
Only one pending @cfg is allowed. not requires exactly one predicate.
Identical conditions are rejected, but RustQ does not prove arbitrary predicates
mutually exclusive or exhaustive; compile every supported Cargo configuration.
implementations of the same signature must use the same NIF scheduling policy.
@cfg also supports defnif: Rust entrypoints, result codecs, and internal
implementation helpers carry their conditions, while Elixir exposes one stub.
Compile every supported configuration to ensure exactly one implementation is
selected. Conditional payload types themselves are not inferred or generated
from feature conditions.
Conditional methods are supported inside defrustimpl. A receiver named other
than self is bound locally to the Rust receiver, preserving ordinary lexical
shadowing. RustQ.Meta.AST.functions/1 returns every conditional function
implementation with its attributes intact. function!/2 rejects ambiguous
names instead of selecting an arbitrary implementation. Callable signatures
remain shared and are not filtered according to the local machine's features.
The AST builder accepts the same recursive structure:
A.attr(:cfg, not: [feature: "native-test"]).
Native closures
Use ordinary Elixir function syntax for callback bodies. move(fn ... end)
explicitly transfers captures into the Rust closure; clone shared values before
capture when required. Moving captures does not by itself imply FnOnce.
@spec callback(String.t()) ::
R.impl((-> R.usize()), traits: [Send.t()], lifetime: R.lifetime(:static))
defrust callback(label) do
move(fn -> label.len() end)
endThis returns impl Fn() -> usize + Send + 'static. R.impl also accepts
kind: :fn_mut and kind: :fn_once; the default is :fn. A 'static bound
forbids retaining shorter-lived borrows, not normal destruction of owned values.
These are native callable types, not BEAM callback codecs.
Tuple patterns and wildcards work in closure parameters. AST generators can
use {pattern, type} arguments with A.closure(args, body, move: true) when an
explicit Rust parameter annotation is necessary. Known callback parameter types
participate in borrowing inference, but generic trait solving is not complete.
Source-backed receiver inference supports a limited set of root type aliases and qualified standard-library pointer types. Unsupported lifetime aliases and requested nested or ambiguous aliases fail explicitly; this is not a complete Rust module or reexport resolver.
Put Rust methods in defrustimpl
When Rusty-Elixir functions belong to a Rust implementation, group them with
defrustimpl rather than retrieving and rewriting function ASTs. The first
argument remains an ordinary Elixir variable. Type it with R.ref/1 for
&self or R.mut_ref/1 for &mut self:
defrustimpl ComponentRegistry, vis: :crate do
@spec input_mut(R.mut_ref(R.path(:ComponentRegistry)), R.str()) ::
R.option(R.mut_ref(R.path(:ComponentInput)))
defrust input_mut(self, id) do
self.entries.get_mut(ref(id))
end
endTrait implementations use :for:
defrustimpl Display, for: Widget do
@spec display(R.ref(R.path(:Widget))) :: R.str()
defrust display(self), do: self.label.as_str()
endImplementation metadata can use :vis, :attrs, and :lifetimes.
Use AST for Rust-only structure
Rust declarations, attributes, unsafe blocks, and other Rust-only structures belong in RustQ AST:
alias RustQ.Rust.AST.Builder, as: A
A.const(:MAX_FIELDS, :usize, A.lit(128), vis: :pub)Generators can access compiled function structure through
RustQ.Meta.AST.functions/1 and RustQ.Meta.AST.function!/2. Do not call the
hidden __rustq_asts__/0 accessor or hidden renderer directly.
Use expr!, pat!, stmt!, and arm! when a semantic Rust-shaped value is
needed inside Rusty-Elixir. Use raw_expr!, raw_pat!, raw_stmt!, and
raw_arm! only as explicit, local token escapes.
See Generating Rust for templates, builders, splices, checked generation, and fragment validation.
Keep real policy explicit
Inference should remove duplication, not decisions. Keep these visible:
- Cargo dependency versions and features
- normal, dirty CPU, or dirty IO scheduling
- resource ownership, synchronization, and thread safety
- blocking and unsafe operations
- lossy conversions and custom adapters
- platform linking and precompiled release targets
RustQ should never infer safety or deployment policy from a function name or body shape.
Avoid these failure modes
String-built functions
Do not assemble complete functions from interpolated Rust fragments. Use Rusty-Elixir, AST, or a parseable template.
Duplicate signature registries
Do not copy names, arities, argument types, lifetimes, and returns into a second manifest when RustQ can derive them from specs, source, schemas, or AST.
Wrappers that only satisfy the generator
Keep wrappers that encode real bridge semantics or stable function-pointer shapes. Remove wrappers whose only purpose is to hide missing metadata.
Fake Elixir modules for Rust paths
Use ordinary external remote types or explicit path metadata. Do not create empty Elixir modules merely to make generated Rust look namespaced.
Architecture tests made from grep
Behavioral tests should compile and exercise generated output. Enforce project architecture with Reach, Credo, ExDNA, or another architecture tool—not brittle source-string assertions.
Porting an existing Rustler binding
- Keep clear domain Rust as Rust.
- Identify duplicated boundary facts and generated glue.
- Expose real Rust through
rust_sourcesorrust_packages. - Move public entrypoints to
defnifwhere RustQ can own the boundary. - Move reusable generated behavior to
defrust/defrustp. - Use AST or templates for remaining generated structure.
- Keep explicit policy in one manifest or native module.
- Check generated files with
mix rustq.gen --checkwhen they are committed. - Run Cargo format, check, Clippy, behavioral tests, and downstream CI.
Do not migrate code merely to make a percentage larger. A clear domain parser or renderer may remain handwritten Rust; repetitive wrappers and codecs are the better first target.
Verification
For non-trivial generator changes, run:
mix ci
mix rustq.gen --check
cargo fmt --check
cargo check
cargo clippy -- -D warnings
Also test at least one real downstream crate when shared inference, metadata, AST, or Rustler generation changes. Clippy-clean Rust is necessary, but the Elixir that generated it must remain readable too.