Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
# Changelog

## 1.0.0-rc.11 - 2026-09-30

### Added

- Describe enum variant payloads in `RustQ.Syn.Enum.variant_shapes`, a list of
`RustQ.Syn.Variant` structs with the variant name, its kind (`:unit`,
`:tuple`, or `:named`), its fields, and its doc comments. `variants` still
lists variant names.
- Report `lifetimes` and `type_parameters` for `RustQ.Syn.Struct` and
`RustQ.Syn.Enum`, matching the existing function and method metadata.
- Add `RustQ.Rustler.Term.encoders_from_source/3` and
`encoder_atoms_from_source/3`, which build `Term` encoder functions and their
atom declarations for structs and enums read from Rust source. They support
types owned by another crate and without `serde`. Any reachable type that is
not indexed, a wrapper, a scalar, or mapped as external fails generation with
a list of the unmapped types, as does a variant tag that would replace a
payload field with the same key. Per-type policy covers excluded fields, key
and variant renames, `with:` helpers, and `transparent: true` structs.
- Add `RustQ.Syn.Index.structs/1`, `struct/2`, and `struct!/2`.
- Add `RustQ.Rust.AST.PatternBuilder.tuple/1`.

### Fixed

- Render Rust keywords used as field names, struct literal and struct pattern
fields, and macro item arguments as raw identifiers (`r#type`). Field access
such as `value.type` previously failed to render.
- Type a literal atom such as `:if_node` in a typespec as `Atom`, like a union
of atoms. It was emitted as a Rust type named after the atom, which does not
compile.

## 1.0.0-rc.10 - 2026-09-28

### Fixed
Expand Down
44 changes: 44 additions & 0 deletions guides/rustler-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,50 @@ 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:

```elixir
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)
]
end
```

Each 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
Expand Down
126 changes: 126 additions & 0 deletions integration/public_consumer/native/src/generated_ir_encoders.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// This file is generated by RustQ. Do not edit by hand.

mod atoms {
rustler::atoms! {
anchor, attrs, clear, content, element, end, is_static, keep_alive, kind, loc,
prop_kind, regular, set_prop, start, text, r#type = "type", value, values
}
}
pub(crate) fn encode_op<'a>(env: rustler::Env<'a>, value: &Op<'_>) -> rustler::Term<'a> {
match value {
Op::SetProp(field0) => {
encode_set_prop(env, field0)
.map_put(atoms::kind().encode(env), atoms::set_prop().encode(env))
.unwrap()
}
Op::Text { element, values } => {
rustler::Term::map_from_arrays(
env,
&[
atoms::kind().encode(env),
atoms::element().encode(env),
atoms::values().encode(env),
],
&[
atoms::text().encode(env),
element.encode(env),
values
.iter()
.map(|item0| encode_expr(env, item0))
.collect::<Vec<rustler::Term<'a>>>()
.encode(env),
],
)
.unwrap()
}
Op::Anchor(field0) => {
rustler::Term::map_from_arrays(
env,
&[atoms::kind().encode(env), atoms::value().encode(env)],
&[atoms::anchor().encode(env), field0.encode(env)],
)
.unwrap()
}
Op::Clear => {
rustler::Term::map_from_arrays(
env,
&[atoms::kind().encode(env)],
&[atoms::clear().encode(env)],
)
.unwrap()
}
}
}
pub(crate) fn encode_set_prop<'a>(
env: rustler::Env<'a>,
value: &SetProp<'_>,
) -> rustler::Term<'a> {
rustler::Term::map_from_arrays(
env,
&[
atoms::element().encode(env),
atoms::values().encode(env),
atoms::loc().encode(env),
atoms::prop_kind().encode(env),
atoms::r#type().encode(env),
atoms::attrs().encode(env),
],
&[
value.element.encode(env),
value
.values
.iter()
.map(|item0| encode_expr(env, item0))
.collect::<Vec<rustler::Term<'a>>>()
.encode(env),
value
.loc
.as_ref()
.map(|item0| encode_loc(env, item0))
.unwrap_or_else(|| rustler::types::atom::nil().encode(env)),
encode_kind(env, &value.kind),
encode_name(env, &value.r#type),
rustler::Term::map_from_pairs(
env,
&value
.attrs
.iter()
.map(|(key0, item0)| (
key0.as_str().encode(env),
encode_expr(env, item0),
))
.collect::<Vec<(rustler::Term<'a>, rustler::Term<'a>)>>(),
)
.unwrap(),
],
)
.unwrap()
}
pub(crate) fn encode_expr<'a>(
env: rustler::Env<'a>,
value: &Expr<'_>,
) -> rustler::Term<'a> {
rustler::Term::map_from_arrays(
env,
&[atoms::content().encode(env), atoms::is_static().encode(env)],
&[value.content.encode(env), value.is_static.encode(env)],
)
.unwrap()
}
pub(crate) fn encode_loc<'a>(env: rustler::Env<'a>, value: &Loc) -> rustler::Term<'a> {
rustler::Term::map_from_arrays(
env,
&[atoms::start().encode(env), atoms::end().encode(env)],
&[value.start.encode(env), value.end.encode(env)],
)
.unwrap()
}
pub(crate) fn encode_kind<'a>(env: rustler::Env<'a>, value: &Kind) -> rustler::Term<'a> {
match value {
Kind::Regular => atoms::regular().encode(env),
Kind::KeepAlive => atoms::keep_alive().encode(env),
}
}
pub(crate) fn encode_name<'a>(env: rustler::Env<'a>, value: &Name) -> rustler::Term<'a> {
value.0.as_str().encode(env)
}
40 changes: 40 additions & 0 deletions integration/public_consumer/native/src/ir.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//! A small IR in the shape of an external crate's types, encoded by
//! `RustQ.Rustler.Term.encoders_from_source/3`.

use std::collections::HashMap;

pub struct Loc {
pub start: u32,
pub end: u32,
}

pub struct Expr<'a> {
pub content: &'a str,
pub is_static: bool,
}

pub struct Name(pub String);

pub enum Kind {
Regular,
KeepAlive,
}

pub struct SetProp<'a> {
pub element: usize,
pub values: Vec<Box<Expr<'a>>>,
pub loc: Option<Loc>,
pub kind: Kind,
pub r#type: Name,
pub attrs: HashMap<String, Expr<'a>>,
}

pub enum Op<'a> {
SetProp(SetProp<'a>),
Text {
element: usize,
values: Vec<Expr<'a>>,
},
Anchor(usize),
Clear,
}
13 changes: 13 additions & 0 deletions integration/public_consumer/native/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@ use rustler::{NifResult, Term};

include!("generated.rs");

pub mod ir;

mod ir_encoders {
use super::ir::*;
use rustler::Encoder;

include!("generated_ir_encoders.rs");
}

pub fn encode_op<'a>(env: rustler::Env<'a>, op: &ir::Op<'_>) -> Term<'a> {
ir_encoders::encode_op(env, op)
}

pub fn increment_values(values: Vec<u32>) -> Vec<u32> {
increment_all(values)
}
Expand Down
10 changes: 10 additions & 0 deletions integration/public_consumer/rustq.exs
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,13 @@ rust "native/src/generated.rs" do
)
]
end

ir_sources = ["native/src/ir.rs"]
ir_opts = [tag: :kind, types: [SetProp: [fields: [kind: [key: :prop_kind]]]]]

rust "native/src/generated_ir_encoders.rs" do
[
Atom.declaration(Term.encoder_atoms_from_source(ir_sources, ["Op"], ir_opts)),
Term.encoders_from_source(ir_sources, ["Op"], ir_opts)
]
end
15 changes: 14 additions & 1 deletion lib/rustq/meta/type.ex
Original file line number Diff line number Diff line change
Expand Up @@ -851,7 +851,9 @@ defmodule RustQ.Meta.Type do
tuple_type(tuple_types)
end

def parse(atom, _aliases) when is_atom(atom), do: type(:type, path(atom))
def parse(atom, _aliases) when is_atom(atom) do
if literal_atom?(atom), do: type(:enum, path(:Atom)), else: type(:type, path(atom))
end

defp parse_remote(module, function, args, aliases) do
if type_module?(module),
Expand Down Expand Up @@ -1261,6 +1263,17 @@ defmodule RustQ.Meta.Type do

defp atom_union?(ast), do: ast |> union_members() |> Enum.all?(&is_atom/1)

@rust_primitives ~w(bool char str u8 u16 u32 u64 u128 usize i8 i16 i32 i64 i128 isize f32 f64)

# A lowercase atom such as `:if_node` in a typespec is an Elixir literal, not a
# Rust type name.
defp literal_atom?(atom) when atom in [nil, true, false], do: false

defp literal_atom?(atom) do
name = Atom.to_string(atom)
String.match?(name, ~r/^[a-z_]/) and name not in @rust_primitives
end

defp option_union?(ast), do: ast |> union_members() |> option_members?()

defp option_members(ast),
Expand Down
2 changes: 2 additions & 0 deletions lib/rustq/rust/ast/pattern_builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ defmodule RustQ.Rust.AST.PatternBuilder do
def ok(pattern), do: %AST.PatOk{pattern: pattern(pattern)}
def err(pattern), do: %AST.PatErr{pattern: pattern(pattern)}

def tuple(patterns), do: %AST.PatTuple{patterns: Enum.map(patterns, &pattern/1)}

def path_tuple(path, patterns),
do: %AST.PatPathTuple{path: A.expr_path(path), patterns: Enum.map(patterns, &pattern/1)}

Expand Down
4 changes: 2 additions & 2 deletions lib/rustq/rust/ast/render.ex
Original file line number Diff line number Diff line change
Expand Up @@ -271,8 +271,8 @@ defmodule RustQ.Rust.AST.Render do
end

defp render_macro_arg({:literal, value}), do: inspect(value)
defp render_macro_arg({name, value}), do: [to_string(name), " = ", inspect(value)]
defp render_macro_arg(value), do: to_string(value)
defp render_macro_arg({name, value}), do: [render_path_part(name), " = ", inspect(value)]
defp render_macro_arg(value), do: render_path_part(value)

def render_impl(%Impl{} = impl) do
items = impl.items |> Elixir.Enum.map(&render_impl_item/1) |> Elixir.Enum.join("\n")
Expand Down
Loading
Loading