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! when you intentionally need to force ? and RustQ cannot infer the
propagation yet:
value = unwrap!(legacy_decoder(term))Do not use it reflexively around every fallible call. Prefer giving RustQ enough metadata to infer.
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.
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.
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.
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.