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:

  1. defnif for a public NIF implemented as Rusty-Elixir.
  2. defrust or defrustp for generated Rust helpers.
  3. Ordinary Elixir functions, macros, quote, and unquote for composition.
  4. Rust source, Cargo package, typespec, and schema metadata for inference.
  5. RustQ AST for Rust-only structure.
  6. Parseable .rs templates for substantial handwritten Rust around generated regions.
  7. 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)
end

Use 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)
end

Do 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
end

Before 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, and callable_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.rs

provides: ["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)}
end

A 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]
end

Then 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, and with
  • 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)
end

RustQ 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.0
pub 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_index indexes in usize, and a generator over it enumerates in place: for {child, index} <- Enum.with_index(children) is for (index, child) in children.iter().enumerate().
  • find_value takes a function returning some(value) or nil.
  • reduce_while reads {:cont, acc} and {:halt, acc} as try_fold does.
  • max_by, min_by, max, and min need an empty fallback, fn -> nil end for an Option, 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, and chunk_every take 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
end

RustQ 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: value

A 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)
end

This 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
end

Trait 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()
end

Implementation 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

  1. Keep clear domain Rust as Rust.
  2. Identify duplicated boundary facts and generated glue.
  3. Expose real Rust through rust_sources or rust_packages.
  4. Move public entrypoints to defnif where RustQ can own the boundary.
  5. Move reusable generated behavior to defrust/defrustp.
  6. Use AST or templates for remaining generated structure.
  7. Keep explicit policy in one manifest or native module.
  8. Check generated files with mix rustq.gen --check when they are committed.
  9. 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.