RustQ is for building readable Elixir↔Rust bridges. It gives you Rusty-Elixir,
defrust, RustQ AST, Rust source introspection, and generator validation so that
bridge code remains understandable as it grows.
RustQ also ships an agent skill file, SKILL.md, in the Hex package and source
repository. On HexDocs it is available at
https://rustq.hexdocs.pm/skill.md. If you
use a coding agent to start a RustQ bridge, port existing bindings, or maintain a
RustQ-powered generator, give the agent SKILL.md before it writes code. The
skill is the short operational version of this guide.
The goal is not to move Rust string concatenation from .rs files into .ex
files. The goal is to use Elixir as a semantic metaprogramming language for Rust
bridges.
The authoring ladder
Before writing generated Rust as a string, ask:
Can this be valid Elixir,
defrust, an ordinary Elixir macro, RustQ AST, or metadata inferred from Rust/source schemas instead?
Use this order:
defrustfor implementation logic- ordinary Elixir macros for reusable Rusty-Elixir fragments
- RustQ AST/builders for generated structure
- Rust/Syn/schema/type introspection for metadata
- tiny raw escapes only where RustQ lacks a representation
defrust first
A defrust function is ordinary Elixir-shaped source that lowers to Rust:
defmodule MyApp.Native.Generated do
use RustQ.Meta
alias RustQ.Type, as: R
@spec read_guid(R.mut_ref(Decoder.t())) :: R.nif_result(Guid.t())
defrust read_guid(decoder) do
session_id = decoder.read_var_uint()
local_id = decoder.read_var_uint()
{:ok, Guid.new(session_id, local_id)}
end
endWhen RustQ has callable metadata for the decoder methods and the function return
type is NifResult<Guid>, it can infer propagation and render the fallible calls
with ?.
Inference is a feature, not a trick
Older Rusty-Elixir code often used unwrap! everywhere to spell Rust ?.
Current RustQ can infer many propagation sites.
Return-position propagation
@spec maybe_path() :: R.option(Path.t())
defrust maybe_path() do
find_path()
endIf find_path/0 is known to return Option<Path>, RustQ can propagate/shape the
return according to the expected return type.
Argument propagation
@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 stroke(R.term(), R.slice({R.atom(), R.term()})) :: R.nif_result(Paint.t())
defrust stroke(term, opts) do
stroke_paint(decode_color(term), 1.0, opts)
endIf stroke_paint/3 expects a Color and returns NifResult<Paint>, RustQ can
render decode_color(term)? and propagate the final call.
Downstream local inference
RustQ can infer the expected type of a binding from later uses:
@spec draw(R.term()) :: R.nif_result(R.unit())
defrust draw(term) do
color = decode_color(term)
canvas.draw_color(color)
:ok
endThe later draw_color/1 call can tell RustQ that color should be the unwrapped
Color, not NifResult<Color>.
When to use unwrap!
Use unwrap! only when you intentionally need to force ? and RustQ cannot infer the propagation yet.
Before reaching for it, check whether the callable is available from:
- a local
@spec - a configured
callable_modulesmodule - configured
rust_sources - configured
rust_packages - a known receiver type and method lookup
- an expected argument or return type
If a fallible call is a method on a Rust type, read the Rust source that defines the method and expose it to RustQ before assuming inference is impossible.
value = unwrap!(legacy_decoder(term))Do not use it reflexively around every fallible call. Prefer giving RustQ enough metadata to infer. If metadata is available but RustQ still cannot infer, treat that as a RustQ improvement candidate rather than normal downstream style.
Use ok_or! for explicit Option<T> to Result/NifResult conversion:
@spec shader(R.ref(Paint.t())) :: R.nif_result(Shader.t())
defrust shader(paint) do
ok_or!(paint.shader(), badarg())
endFeed RustQ real Rust metadata
Configure RustQ with real Rust sources and packages instead of copying Rust APIs into Elixir:
defmodule MyApp.Native.Generated do
use RustQ.Meta,
rust_sources: ["native/my_app_nif/src/helpers.rs"],
rust_packages: [{"skia-safe", manifest_path: "native/my_app_nif/Cargo.toml"}],
callable_modules: [MyApp.Native.GeneratedEnums]
alias RustQ.Type, as: R
@spec run(R.mut_ref(Paint.t()), R.atom()) :: R.nif_result(R.unit())
defrust run(paint, atom) do
paint.set_stroke_cap(decode_cap(atom))
:ok
end
endRustQ parses functions, impl methods, aliases, argument types, and return types through RustQ.Syn/binding metadata and uses that information while lowering.
For example, if generated code calls a downstream Rust Decoder method such as decoder.read_var_int64(), the right first step is to expose the Decoder implementation through rust_sources or equivalent callable metadata. Do not retype the method signature into an ad hoc Elixir table, and do not hide missing metadata behind unwrap!, verbose case propagation, or trivial wrappers.
Do not paper over missing metadata with trivial wrappers
A wrapper that only calls one Rust method and returns unit is usually a smell if it exists only because RustQ cannot infer propagation:
# Avoid this as a metadata workaround.
@spec skip_int64(R.mut_ref(R.path(:Decoder, R.lifetime(:_)))) :: R.nif_result(R.unit())
defrust skip_int64(decoder) do
unwrap!(decoder.read_var_int64())
:ok
endFirst make the underlying Rust method visible through rust_sources, rust_packages, or callable_modules, or improve RustQ inference. Keep wrappers when they encode real bridge semantics or provide a stable function pointer shape, but do not use them to avoid reading the actual Rust API.
Prefer recursion and reducers over Rusty exits
Rust has return, loop, break, and continue. RustQ has internal AST nodes
for them. That does not mean product bridge code should be written in that style.
Prefer recursion for small state machines:
@spec skip_many(R.mut_ref(Decoder.t()), R.u32()) :: R.nif_result(R.unit())
defrust skip_many(decoder, remaining) do
if remaining == 0 do
:ok
else
skip_one(decoder)
skip_many(decoder, remaining - 1)
end
endPrefer for ..., reduce: for accumulator loops:
@spec validate_all(R.vec(Item.t())) :: R.nif_result(R.unit())
defrust validate_all(items) do
for item <- items, reduce: :ok do
:ok -> validate_item(item)
end
endReach for return! only when the early-exit shape is genuinely the clearest low-level Rust primitive.
Normal Elixir macros are the composition layer
defmacro with_saved_canvas(do: body) do
quote do
var!(canvas).save()
unquote(body)
var!(canvas).restore()
end
end
@spec draw(R.ref(Canvas.t())) :: R.nif_result(R.unit())
defrust draw(canvas) do
with_saved_canvas do
canvas.translate({1.0, 2.0})
end
:ok
endRustQ expands ordinary Elixir macros before lowering. Use that instead of building a separate Rust string DSL.
Use ordinary Elixir macros when you want to make the Rusty-Elixir source more reusable or readable. They run while compiling the Elixir generator and usually disappear before Rust exists.
defrustmacro for compact generated Rust
Use defrustmacro for a different job: keeping the generated Rust output
small. When generated Rust repeats the same small implementation pattern many
times, defrustmacro can define one Rust macro_rules! helper from a
Rusty-Elixir body:
defrustmacro field(term, name, type: :ty) do
decode_as!(required_field(term, name), type)
end
@spec decode(R.term()) :: R.nif_result(R.u32())
defrust decode(term) do
field!(term, "value", R.u32())
endPlain arguments are Rust :expr fragments. Annotate type arguments with :ty.
Use :ident and :literal for Rust identifier and literal captures. The body is
still Rusty-Elixir: use ordinary calls, decode_as!/2, propagation inference,
pattern matching, and semantic helpers rather than Rust token syntax. Keep these
macros small and supportive; they are for reducing generated Rust bulk, not for
hiding large bridge functions.
defrustmacro can emit Rust items too. The item implementation should still be
written as defrust, not as a hand-built function AST:
defrustmacro sparse_message(
fn: name(:ident),
env: env(:ident),
decoder: decoder(:ident),
module: module_name(:literal),
capacity: capacity(:literal),
fields:
repeat do
field_id(:literal)
field_name(:literal)
field_mode(:ident)
field_decode(:ident)
end
) do
@spec name(R.path(:Env, R.lifetime(:a)), R.mut_ref(R.path(:Decoder))) ::
R.nif_result(term())
defrust name(env, decoder) do
decode_sparse_fields(
env,
decoder,
module_name,
capacity,
ref(
array([
repeat fields do
struct_literal(Field,
id: field_id,
name: field_name,
repeated: repeated!(field_mode),
decode: field_decode
)
end
])
)
)
end
endInside the macro body, declared captures such as env, module_name, and
field_id lower to Rust macro variables ($env, $module_name, $field_id).
repeat fields do ... end is macro-template repetition ($()*), not a runtime
loop. Use it for repeated token families that should stay compact in generated
Rust.
Rule of thumb:
- Use
defmacroto reuse or generate Rusty-Elixir before lowering. - Use
defrustmacroonly when the emitted Rust should contain and call a Rust macro to avoid repeated Rust code.
Typespecs are the signature source of truth
Prefer ordinary Elixir and remote types where possible:
@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 for Rust-specific forms:
R.ref/1,R.mut_ref/1,R.slice/1R.u32(),R.i64(),R.f32(), etc.R.nif_result/1,R.result/2,R.option/1,R.vec/1R.lifetime/1R.raw/1andR.path/1,2as low-level escapes
Avoid fake Elixir modules that exist only to force Rust paths.
Generate Rust type items from @type
RustQ can use ordinary Elixir @type aliases as more than function-signature
metadata: aliases can also become Rust type items. Prefer this when a generator
needs small support structs, enums, tuple enums, or aliases for defrust
helpers.
Map types become Rust structs:
@type point :: %{
required(:x) => R.f32(),
required(:y) => R.f32()
}This emits a Rust struct shaped like:
pub struct Point {
pub x: f32,
pub y: f32,
}Elixir struct types also become Rust structs:
defmodule Click do
defstruct [:name]
end
@type click :: %Click{name: String.t()}Atom unions become Rust enums:
@type mode :: :src_over | :multiplyUnions of struct types become tuple enums:
@type event :: click() | resize() | scroll()Use R.enum(...) for explicit Rust enum items with tuple payloads when ordinary
Elixir unions would be ambiguous or too implicit:
@type skip_kind :: R.enum(one: [skip_fn()], repeated: [skip_fn()], bytes: [])Construct those variants in Rusty-Elixir with enum_variant/2+ rather than raw
Rust or token macros:
defrust kind(repeated, skip) do
if repeated do
enum_variant(SkipKind, :repeated, skip)
else
enum_variant(SkipKind, :one, skip)
end
endThe generated items are exposed through __rustq_type_items__/0, alongside
__rustq_items__/0 for functions/macros. Generators can use those type items as
structural RustQ fragments instead of writing Rust declaration strings.
Use this path for descriptor/support records that are consumed by defrust
helpers. Reach for explicit RustQ AST builders only when @type cannot express
the shape; add RustQ support rather than falling back to large raw templates.
Semantic helpers and raw escapes
Use semantic helpers when you need Rust-shaped AST values inside Rusty-Elixir:
expr!({:ok, value})
pat!({:ok, value})
stmt!(canvas.clear(color))
arm!({:ok, value}, value)Use raw token escapes only when the semantic form does not exist yet:
raw_expr!("unsafe { make_term(env, value) }")If raw escapes spread or become repeated patterns, add a RustQ lowering rule, AST node, or helper.
RustQ AST for generated structure
Use builders for declarations and data-shaped Rust generation:
alias RustQ.Rust
alias RustQ.Rust.AST.Builder, as: A
Rust.ast_item(A.const(:MAX_FIELDS, :usize, A.lit(128), vis: :pub))If the AST cannot represent a needed construct, that is a RustQ feature request, not permission to create large string templates.
Explicit escape boundaries
RustQ has explicit escape boundaries. They exist so low-level integration points are honest about being low-level:
- render/template entry points validate real Rust text
MacroItem,EscapeExpr, andTypeRaware explicit AST escape nodes- some Rustler helpers accept caller-provided Rust expressions for advanced dispatch or defaults
- unsafe raw
NIF_TERMhelpers may need handwritten Rust because they sit at the Rustler wrapper boundary
Do not treat those boundaries as a normal generator style. Outside them, prefer
defrust, RustQ AST, or inferred metadata.
Bad patterns
String-built functions
Rust.item([
"fn decode_", name, "(decoder: &mut Decoder<'_>) -> NifResult<()> {\n",
" loop { ... }\n",
"}\n"
])This hides semantics and makes the generator hard to maintain.
Duplicated metadata
@primitive %{"uint" => "decoder.read_var_uint()?"}
@primitive_decoders [{"uint", :read_var_uint, []}]Use one source of truth and derive the other forms.
Rewriting Rust metadata by hand
If Rust owns the type/function/method, parse the Rust. Do not maintain an Elixir shadow registry unless there is no better source.
Porting existing Rustler bindings
- Keep clear domain Rust as Rust.
- Move repetitive NIF glue, decoders, option handling, and helper dispatch into
defrustor RustQ AST. - Configure
rust_sources/rust_packagesbefore duplicating signatures. - Use
callable_modulesto reuse metadata from generated RustQ modules. - Generate via
rustq.exs; check freshness in CI. - Run generated Rust through format/check/clippy.
Dogfooding and downstream packages
The same rules apply more strictly inside RustQ and RustQ-powered generators:
- grow RustQ's semantic vocabulary before spreading string templates downstream
- keep generic machinery in generic packages and product semantics in product packages
- use behavioral tests and generated-output checks, not brittle policy grep tests
- treat raw escapes as candidates for future RustQ support
API references
Useful modules to read in HexDocs/source:
RustQ.MetaRustQ.TypeRustQ.Meta.LowerRustQ.Meta.InferenceRustQ.Binding.CallableRustQ.Binding.SourceRustQ.Binding.IndexRustQ.SynRustQ.Syn.IndexRustQ.Rust.AST.BuilderRustQ.Rust.AST.PatternBuilderRustQ.Rust.AST.TypeBuilderRustQ.RustlerRustQ.Rustler.Schema
Verification
mix cimix rustq.gen --checkcargo fmt --checkcargo checkcargo clippy -- -D warnings- downstream dogfood for shared generator changes
Generated Rust being Clippy-clean is necessary. It is not sufficient. The Elixir that generates it should also be readable and beautiful.