The Grimoire’s Preface
xrune is an engine of inscription.
You hand it a casting — a declarative cast written inside ui! { … } —
and at compile time it reads the shape of that cast, transcribes the
shape into runes, and lets the inscriber you have bound (a DsRune
implementation) inscribe those runes into actual code. A final seal
closes the rite, and the whole casting condenses into a TokenStream
your host crate absorbs as if it had written it by hand.
That separation is the entire design. The same casting can be inscribed into ECS spawn calls, into a render-tree builder, into a debug echo, or round-tripped by the scribe. What it becomes is decided not by the casting, but by which inscriber you bind.
The casting does not know what a “widget” is. The casting knows only shape.
When to summon xrune
- Your host already owns its components, its state, its render-loop, and you want a casting layer above them — without wedding the casting to any one type system.
- Several inscribers must share one casting: an ECS-runtime inscriber, a pretty-printing inscriber, a static-analysis inscriber — all reading the same rite.
- The rendering target is not yet decided, but the surface casting is.
If your DSL needs compile-time type-checking on widget names and attribute keys, look elsewhere — toward a typed-builder DSL. xrune hands every shred of semantics to the inscriber.
Five volumes and a hub
| Volume | Suffix | Office |
|---|---|---|
xrune | — | The opening scroll. The reader summons only this; it draws xrune-nexus and the ui! rite from beneath. |
xrune-nexus | nexus | The hub. AST nodes (Ds*), the DsRune covenant, the decipher walk — all kept here. |
xrune-incant | incant | The speaking-stone. The proc-macro that is ui! { … }. |
xrune-sigil | sigil | The sigil-forge. The DsRef derive macro that mints Rc<RefCell<>> reference-sigils for the AST. |
xrune-fmt | fmt | The scribe. A CLI that puts every ui! { … } block through the real parser and writes it back, faithful in shape. |
All five volumes share one cargo workspace and one moving version: every
release advances all five together to the same X.Y.Z.
The naming compact
xrune leans on a medieval-magical vocabulary because the architecture is shaped that way: one casting, many translations. A reader who meets an unfamiliar term should look it up once in the Lexicon and never need to look it up again. The shortest summary:
- rune / inscriber: a backend; an implementation of the
DsRunetrait. - decipher: the walk function. It traverses the AST and feeds every node to the inscriber’s inscribe methods.
- inscribe: what the inscriber does at every node — laying each rune into the emitted code.
- seal: when the walk ends, the inscriber closes its work into a
final
TokenStream.
That is the whole conceptual surface. Every other word — sigil, niche, enchant, walk, on — is a particular shape of casting; the chapters ahead take them in turn.
How the grimoire is laid out
- Part I — First Casting takes you from
cargo newto seeingdecipheractually walk, then catalogues every casting shape. - Part II — The Inner Mechanism explains the AST nodes and teaches you to forge an inscriber of your own.
- Part III — The Outer Tools covers the scribe and the workshop (the cargo workspace itself).
- Part IV — Time & Lore records the cross-version drift of forms, and sets xrune beside neighbouring DSL approaches.
The Appendix is the term-by-term lexicon and the public-API index.
House style
- Code blocks are real, copy-paste-runnable Rust unless marked
otherwise. The few that demonstrate
ui!parser output without arranging a host environment are flagged in place. - Type names, error messages, and CLI strings are quoted from the source. They are never paraphrased.
The First Incantation
A five-minute walk from cargo new to seeing decipher actually run.
The example does not render a UI — there is no widget runtime in this
book. What it produces is the proc-macro expansion of the bundled
DefaultRune: a stream of println! calls that traces every node the
parser hands to the rune. That is the entire learning surface for this
chapter. Real backends come later in Binding the Rune.
Set up
cargo new hello-xrune
cd hello-xrune
Cargo.toml:
[package]
name = "hello-xrune"
version = "0.1.0"
edition = "2024"
[dependencies]
xrune = "1.5"
The minimum cast
src/main.rs:
use xrune::ui;
fn app(parent: i32) {
ui! {
:(
parent: parent
:)
container (width: 100, height: 100) {}
}
}
fn main() {
app(0);
}
⚠ The ▶ button posts to play.rust-lang.org, which doesn’t carry the
xrunecrate, so the run will fail there. Use the eye ( 👁 ) toggle to reveal the full program, copy it into a localcargo newproject withxrune = "1.5"inCargo.toml, andcargo run.
Run cargo run locally. You will see the DefaultRune trace:
inscribe_root: 0
inscribe_widget: container, attrs: [width: 100, height: 100], children: []
(Exact strings depend on the version; the structure does not.)
That’s it — the parser accepted the ui! block, the decipher walker
visited every node, and the bundled rune printed what it saw. Nothing else
happened. No widgets exist; no window opened.
A slightly larger cast
The canonical fixture in examples/example0
exercises every Phase-1 syntax form:
use xrune::ui;
static A: i32 = 20;
fn app(parent: i32) {
ui! {
:(
parent: parent
:)
div (
width: 100,
height: 100 + A,
color: "red"
) {
text (content: "hello world") {
picker (values: vec!["1", "2", "3"]) {
}
}
walk range(20) with i {
button (text: 6) {}
}
if a == "1" {
input {
}
}
}
}
}
fn main() {}
Things to read out of this:
- The
:( ... :)block (withparent: parenton its own line) is the context area.parentis the only required key; the rune sees it viaDsRoot::get_parent(). width: 100, height: 100 + A, color: "red"— attribute values are arbitrarysyn::Expr.100 + Ais a Rust expression, not a string.text (content: "hello world") { picker (…) {} }— children nest. The parser builds a tree ofDsTreecells; the rune decides what nesting means.walk range(20) with i { … }— iteration.range(20)is not a standard-library function. This example compiles only as far as the proc-macro expansion; the expanded code references symbols that don’t exist in plain Rust. That’s fine for learning the syntax; for a runnable end-to-end example you need a real rune (Part II).if a == "1" { … }— conditional. Likewise,ais a free identifier here.
What just happened, in one paragraph
ui! { … } is a proc macro shipped by xrune-incant.
At expansion time it parses the token stream into a DsRoot (the AST root),
constructs the bundled DefaultRune, calls inscribe_root with the
context’s parent expression, and then runs decipher over the children.
Each visited node — widget, if, walk, @niche, match — triggers one
inscribe_* method on the rune. At the end the rune is sealed and its
accumulated TokenStream becomes the macro’s output. For DefaultRune
that output is a sequence of println! calls, which is why this example
“runs” without a UI runtime.
Next
- The Casting Syntax — every DSL form, exhaustively.
- Binding the Rune — replace
DefaultRunewith one that actually emits real code.
The Casting Syntax
Every form the ui! { … } macro accepts. Six axes:
- Context Area — the
:( … :)header that opens every cast. - Widget Nodes — the heart of the language: named or positional attrs, optional parens, optional body.
- Enchants — the
[expr, expr, …]block that attaches arbitrary data to a widget. - Control Flow —
if,walk … with …,@niche,match. - The
onHandlers — event clauses in two forms (B and C), body or callback, qualified or bare, with or without args. - Rejected Forms — what the parser refuses, and why.
The DSL is untyped at parse time. Widget names are arbitrary
identifiers; attribute values are arbitrary syn::Expr. All semantics —
what counts as a widget, which attrs are valid, what an event kind means —
live in the rune you bind, never in xrune itself.
Context Area
Every ui! { … } block opens with a context header:
use xrune::ui;
fn app(parent_expr: i32) {
ui! {
:(
parent: parent_expr
:)
placeholder {}
}
}
fn main() {}
:( and :) are literal token pairs. Inside live one or more attributes
in the same name: value shape used elsewhere in the DSL. Each attribute
sits on its own line.
What parent means
parent is the only required context key. The parser rejects a header
without it:
Root node must have a parent
The rune retrieves the parent expression via DsRoot::get_parent(). For
DefaultRune this is the value passed to inscribe_root; a real backend
typically threads it as the spawn-under / mount-on entity for the rest of
the cast.
Other keys are rune-defined
get_context_attrs() returns every attribute the header carried. xrune
itself only consumes parent; everything else is yours to interpret:
use xrune::ui;
struct App { world: u32 }
enum Theme { Dark }
fn run(root_entity: i32, app: &mut App) {
ui! {
:(
parent: root_entity,
world: &mut app.world,
theme: Theme::Dark
:)
placeholder {}
}
}
fn main() {}
A rune that doesn’t understand world simply doesn’t read it. There is
no compile-time validation that theme is a real symbol — that’s the
rune’s job, in inscribe_root or in a sealing pass.
Multi-line layout
The header always spans multiple lines, with each attribute on its own line:
use xrune::ui;
struct App { world: u32 }
enum Theme { Dark }
fn run(root_entity: i32, app: &mut App) {
ui! {
:(
parent: root_entity
world: &mut app.world
theme: Theme::Dark
:)
placeholder {}
}
}
fn main() {}
Putting two or more attributes on the same line raises a parse error:
root header must be multi-line — put each context attr on its own line
Commas between attributes are optional — the shape below is equally valid:
use xrune::ui;
fn run(parent: i32, world: &mut u32) {
ui! {
:(
parent: parent,
world: &mut world,
:)
placeholder {}
}
}
fn main() {}
Source-of-truth
DsRoot::parse in
crates/xrune_nexus/src/ds_node/ds_root.rs.
The behaviour above is exercised by root_header_* tests in
crates/xrune_nexus/src/tests.rs.
Widget Nodes
The most-used form. The parser produces a DsWidget node carrying:
- a name (
syn::Ident), - attrs (
Vec<DsAttr>), - enchants (
Vec<syn::Expr>), - on-handlers (
Vec<DsOn>), - children (
Vec<DsTreeRef>).
Backends consume the lot through inscribe_widget(name, attrs, enchants, on_handlers, children).
All shapes
use xrune::ui;
fn Text(s: &'static str) -> &'static str { s }
#[derive(Debug)]
struct DisabledMarker;
fn app(parent: i32) {
let Disabled = DisabledMarker;
ui! {
:(
parent: parent
:)
root_widget {
/* Full: name + named attrs + body. */
container (width: 480, height: 320, color: "dark") {
placeholder {}
}
/* No parens: zero attrs. */
container {}
/* No body: zero children. */
header (height: 40, text: "Hello")
/* Empty body equals no body. */
header (height: 40, text: "Hello") {}
/* Positional attrs (DsAttr.name = None). */
text ("hello world")
button (Text("Save"), Disabled)
}
}
}
fn main() {}
Named vs positional attrs
DsAttr carries name: Option<syn::Ident>. The parser tries the
name: value shape first and falls back to bare-expression positional
attrs. Mixing both in the same widget is allowed — order is preserved.
use xrune::ui;
fn Text(s: &'static str) -> &'static str { s }
#[derive(Debug)]
struct DisabledMarker;
fn app(parent: i32) {
let Disabled = DisabledMarker;
ui! {
:(
parent: parent
:)
button (Text("Save"), priority: 1, Disabled)
}
}
fn main() {}
name_str() gives the rune the attr name as Option<&str> for matching;
positional attrs come back as None.
Attribute values are real Rust
Attribute values are syn::Expr. Anything that parses as a Rust
expression works:
use xrune::ui;
use std::fmt;
struct State;
impl State { fn set(&self, _: u32) {} }
struct DebugClosure<F>(F);
impl<F> fmt::Debug for DebugClosure<F> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("<closure>") }
}
fn app(parent: i32, items: Vec<u32>, state: State) {
let on_change = DebugClosure(move |v| state.set(v));
ui! {
:(
parent: parent
:)
slider (
min: 0,
max: items.len() as u32,
step: 1.0 / 60.0,
on_change: on_change
)
}
}
fn main() {}
The rune decides what to do with each value. There is no compile-time schema mapping attr names to types.
Children nest as full trees
use xrune::ui;
#[derive(Debug)]
struct Item { name: String }
fn app(parent: i32, items: Vec<Item>, item: &Item) {
ui! {
:(
parent: parent
:)
window (title: "Cast") {
column (gap: 8) {
header (text: "xrune")
list (items: items.iter()) {
row (text: item.name)
}
}
}
}
}
fn main() {}
Each child is itself a DsTree, so widgets, if, walk, @niche, and
match can all nest inside another widget’s body. The combination is
unrestricted — the rune enforces what’s actually meaningful.
Source-of-truth
crates/xrune_nexus/src/ds_node/ds_widget.rs.
Tested by parse_widget_* cases in tests.rs.
Enchants
An enchant is a bracketed expression list attached to a widget, sitting between the attrs and the body:
use xrune::ui;
struct Velocity { vx: i32, vy: i32 }
struct Collider;
impl Collider { fn circle(_: i32) -> Self { Self } }
fn app(parent: i32) {
ui! {
:(
parent: parent
:)
physic_obj (x: 100, y: 200) [
Velocity { vx: 1, vy: 0 },
Collider::circle(10),
] {}
}
}
fn main() {}
Enchants are arbitrary syn::Expr values — typically component literals,
struct constructors, or marker tags. The rune retrieves them via
DsWidget::get_enchants() and decides what to do (spawn them as ECS
components, attach them as middleware, store them on the entity).
Position
The order is fixed: name (attrs) [enchants] { children }. All four
parts after name are independently optional:
use xrune::ui;
#[derive(Debug)] struct TagMarker;
#[derive(Debug)] struct Tag1Marker;
#[derive(Debug)] struct Tag2Marker;
fn app(parent: i32) {
let Tag = TagMarker;
let Tag1 = Tag1Marker;
let Tag2 = Tag2Marker;
ui! {
:(
parent: parent
:)
root_widget {
foo /* no parens, no enchants, no body */
foo (a: 1) /* attrs only */
foo (a: 1) [Tag] {} /* attrs + enchants + empty body */
foo [Tag1, Tag2] {} /* enchants without attrs */
foo () [Tag] {} /* equivalent: empty attrs + enchants */
}
}
}
fn main() {}
The rune always receives a Vec for each slot — empty vectors when the
shape was omitted.
Use-case shape
Enchants are intentionally untyped — they’re how a rune lets users attach anything to a node without reserving a syntactic slot for it. A typical ECS-shaped rune turns each enchant expression into a component inserted onto the spawned entity:
use xrune::ui;
struct Position { x: f32, y: f32 }
struct Health(i32);
const PlayerControlled: () = ();
fn app(parent: i32) {
ui! {
:(
parent: parent
:)
sprite (texture: "hero.png") [
Position { x: 0.0, y: 0.0 },
Health(100),
PlayerControlled,
] {}
}
}
fn main() {}
A debug rune simply prints them. xrune-fmt re-emits them verbatim.
Source-of-truth
The parsing branch lives in DsWidget::parse (ds_widget.rs).
inscribe_widget carries an enchants: &[syn::Expr] slice for the rune
to consume.
Control Flow
Four shapes: if, walk … with …, @niche, match. All four sit at the
same level as widget nodes — they can be nested anywhere a widget can be.
if — conditional render
use xrune::ui;
fn app(parent: i32, show_footer: bool) {
ui! {
:(
parent: parent
:)
if show_footer {
footer (height: 20) {}
}
}
}
fn main() {}
The condition is a full syn::Expr parsed without consuming braces (so
the body block is a separate { … }). The body is required — a
bodiless if is a parse error.
The rune sees this through inscribe_if(condition, children). There is
no else arm at the DSL level; render two if blocks with negated
conditions, or use match for binary cases.
walk … with … — iteration
use xrune::ui;
#[derive(Debug)]
struct Item { name: String }
fn app(parent: i32, items: Vec<Item>, item: &Item) {
ui! {
:(
parent: parent
:)
walk items.iter() with item {
label (text: item.name)
}
}
}
fn main() {}
Reads as: iterate items.iter(); for each value, bind it to item in
the children. The iterable is a syn::Expr, the binding is a syn::Ident,
the body is required.
walk and with are reserved keywords — neither can be used as a widget
name.
The rune sees this through inscribe_iter(iterable, variable, children).
@niche — named anchor
use xrune::ui;
fn app(parent: i32) {
ui! {
:(
parent: parent
:)
@settings_panel {
toggle (label: "Dark mode")
slider (label: "Volume", min: 0, max: 1)
}
}
}
fn main() {}
A niche is a @-prefixed identifier carrying a body. The semantics are
entirely the rune’s: a portal slot, a named region, a router target, a
template hole. The parser only guarantees the shape @name { children }.
The rune sees inscribe_niche(name, children).
match — pattern matching
use xrune::ui;
#[derive(Debug)]
enum State { Loading, Ready(Vec<i32>) }
fn app(parent: i32, state: State, data: &Vec<i32>) {
ui! {
:(
parent: parent
:)
match state {
State::Loading => {
spinner {}
}
State::Ready(data) => {
list (items: data.iter()) {}
}
_ => {
empty {}
}
}
}
}
fn main() {}
Each arm carries its own syn::Pat and a sub-tree of children. Patterns
support all the things Pat::parse_multi_with_leading_vert accepts —
bindings, wildcards, | alternatives, struct destructuring. Optional
trailing comma per arm.
The rune sees inscribe_match(scrutinee, arms) and is responsible for
walking each arm’s get_children() itself.
Bodies are mandatory
All four control nodes require a brace body. Bodiless if, walk,
@niche, and match are parse errors — they would be no-ops.
Source-of-truth
if→ds_if.rswalk … with …→ds_iter.rs@niche→ds_niche.rsmatch→ds_match.rs
The on Handlers
The on EventKind clause attaches event handlers to a widget.
on is a reserved keyword — registered as a custom token so on Foo is
never mistaken for a widget named on.
Form B — modifier chain after the body
use xrune::ui;
fn save() {}
fn app(parent: i32) {
ui! {
:(
parent: parent
:)
button (text: "Save") {} on Tap {
save();
}
}
}
fn main() {}
Inside a nested body, Form-B attaches to the nearest preceding sibling widget, not to the parent:
use xrune::ui;
fn save() {}
fn cancel() {}
fn app(parent: i32) {
ui! {
:(
parent: parent
:)
column {
button (text: "Save") {}
on Tap { save(); } /* attaches to the button above */
button (text: "Cancel") {}
on Tap { cancel(); } /* attaches to the cancel button */
}
}
}
fn main() {}
Multiple Form-B clauses chain on the same widget:
use xrune::ui;
fn save() {}
fn hint() {}
fn app(parent: i32) {
ui! {
:(
parent: parent
:)
button (text: "Save") {}
on Tap { save(); }
on Hover { hint(); }
}
}
fn main() {}
Form C — between attrs and body
use xrune::ui;
fn commit() {}
fn lock() {}
fn app(parent: i32) {
ui! {
:(
parent: parent
:)
slider (min: 0, max: 100)
on ValueChanged(2) { commit(); }
on DragStart { lock(); }
{}
}
}
fn main() {}
Multiple Form-C clauses stack on the same widget. The trailing {} is
optional; without it, the widget simply has no children:
use xrune::ui;
fn fire() {}
fn app(parent: i32) {
ui! {
:(
parent: parent
:)
view ()
on Tap { fire(); }
}
}
fn main() {}
Form B and Form C accumulate into the same Vec<DsOn> retrieved via
DsWidget::get_on_handlers(). Mixing both on one widget is allowed.
Body form vs callback form
A handler can carry either a body block:
use xrune::ui;
struct State; impl State { fn toggle(&self) {} }
fn app(parent: i32, state: State) {
ui! {
:(
parent: parent
:)
button () {}
on Tap {
state.toggle();
}
}
}
fn main() {}
…or a trailing callback expression with no body:
use xrune::ui;
fn callback() {}
fn app(parent: i32) {
ui! {
:(
parent: parent
:)
button () {}
on Tap(callback)
on Tap(2, callback)
}
}
fn main() {}
For the body form, DsOn::get_body() returns Some(&syn::Block). For the
callback form, it returns None, and the rune decides what the trailing
get_args() element means — the convention is “callable expression to
invoke when the event fires.”
A clause with neither body nor args is a parse error.
Qualified events
use xrune::ui;
struct Slider;
fn commit() {}
fn app(parent: i32) {
ui! {
:(
parent: parent
:)
slider () {}
on Slider::ValueChanged { commit(); }
}
}
fn main() {}
get_qualifier() returns Some(Slider) and get_name() returns
ValueChanged. Only one segment of qualification is allowed —
Foo::Bar::Baz is rejected.
Args
Arguments inside on EventKind(…) are a comma-separated list of
syn::Expr. The rune retrieves them via get_args(). Common shapes:
on Tap { … } /* args: [], body present */
on Tap(2) { … } /* args: [2], body present */
on Tap(cb) /* callback form, body absent */
on Tap(2, cb) /* count + callback */
A clause with neither body nor args is a parse error: every on
must carry at least one of the two.
Source-of-truth
ds_on.rs
and the form_b_* / form_c_* test cases in tests.rs. The shape is
exhaustively round-tripped by xrune-fmt’s formatter — every change to
this surface lands a fmt update in the same commit.
Rejected Forms
Not every shape that looks sensible parses. The cases below all raise deliberate parse errors — the parser favours rejecting ambiguous casts over guessing.
Context area
| What you wrote | Why it fails |
|---|---|
:( :) (no parent) | Root node must have a parent |
:( foo: 1 :) | Same — missing parent key |
:( parent: r world: w :) (multi-attr, single line) | Multi-line required when >1 attr |
if / walk / @niche / match without bodies
if cond /* parse error: body required */
walk it with x /* parse error */
@slot /* parse error */
match e /* parse error */
Bodyless control nodes would be no-ops, so the parser refuses them outright rather than silently accept dead syntax.
on shapes
| Rejected | Reason |
|---|---|
on Tap { … } at the root, no preceding widget | on requires a sibling to attach to |
on Tap call_me() {} | A handler must be either a body block or (args) form, not a bare call |
on Foo::Bar::Baz { … } | Qualifier supports a single segment only |
on Tap with no body and no args | A handler must carry at least one of the two |
Niche names are single identifiers
@foo::bar { } is not parsed — niche names are a single syn::Ident.
Patterns inside match use Pat::parse_multi_with_leading_vert, so any
pattern syn accepts in a regular match arm works.
What’s not an error
A widget without children is fine — header (text: "x") and
header (text: "x") {} parse identically. So is a widget without parens —
container {} carries zero attrs and zero children. Those shapes are
valid; readers sometimes assume otherwise.
Where rejections live
Every error case has a unit test in
crates/xrune_nexus/src/tests.rs.
The test names start with error_* — they’re a useful catalogue when a
real ui! block fails to parse and the message isn’t immediately clear.
The Runes
Drafting in progress. The English version of this chapter has not landed yet. Track issue #docs for the call to scribes.
Binding the Rune
A rune is a backend — an implementation of the DsRune trait. The
parser hands you a tree; the rune turns that tree into emitted code.
This chapter walks the trait method by method, then reads the bundled
DefaultRune as a worked example.
The trait
DsRune declares seven methods. None has a default implementation —
every concrete rune must provide all seven.
pub trait DsRune {
fn inscribe_root(&mut self, parent_expr: &syn::Expr);
fn inscribe_widget(
&mut self,
name: &syn::Ident,
attrs: &[DsAttr],
enchants: &[syn::Expr],
on_handlers: &[DsOn],
children: &[DsTreeRef],
);
fn inscribe_if(&mut self, condition: &syn::Expr, children: &[DsTreeRef]);
fn inscribe_iter(
&mut self,
iterable: &syn::Expr,
variable: &syn::Ident,
children: &[DsTreeRef],
);
fn inscribe_niche(&mut self, name: &syn::Ident, children: &[DsTreeRef]);
fn inscribe_match(&mut self, scrutinee: &syn::Expr, arms: &[DsMatchArm]);
fn seal(self) -> proc_macro2::TokenStream;
}
How decipher calls them
decipher(tree, &mut rune) walks the AST and dispatches inscribe methods.
The crucial detail:
decipheronly auto-recurses into the root. Every other inscribe method receives achildrenslice (or, forinscribe_match, anarmsslice where each arm carries its own children) and is responsible for recursing itself.
⚠ This is xrune’s most common foot-gun. If your
inscribe_widgettakeschildrenand forgets to calldecipher(child, self), the subtree is silently dropped — no error, no warning, the output just stops mid-tree. Every non-root inscribe method needs that recursion.
That means inscribe_widget typically looks like:
fn inscribe_widget(
&mut self,
name: &syn::Ident,
attrs: &[DsAttr],
enchants: &[syn::Expr],
on_handlers: &[DsOn],
children: &[DsTreeRef],
) {
/* … emit widget construction code for `name`, `attrs`, etc. … */
for child in children {
decipher(child, self); // omit this and the subtree disappears
}
/* … emit any post-children fixup … */
}
inscribe_if, inscribe_iter, and inscribe_niche follow the same
single-loop shape.
inscribe_match is the exception. Its signature takes arms: &[DsMatchArm]
only — there is no separate children slice. Each arm carries its
own get_children(), so the rune writes a two-level loop: outer over
arms, inner over each arm’s subtree.
fn inscribe_match(&mut self, scrutinee: &syn::Expr, arms: &[DsMatchArm]) {
/* … emit match header … */
for arm in arms {
/* … emit this arm's pattern header … */
for child in arm.get_children() {
decipher(child, self);
}
/* … emit this arm's footer … */
}
}
The rune drives depth; decipher dispatches one level at a time. This
is intentional: it gives the rune full control over order (emit parent
before children, both interleaved, or only after the whole subtree is
known) and scope (push a parent symbol onto a stack before recursing,
pop it after).
The sealing pattern
seal(self) -> TokenStream consumes the rune by value at the very end.
Everything inscribed during the walk gets accumulated into the rune’s
internal state — typically a proc_macro2::TokenStream field — and seal
returns it.
struct MyRune {
out: proc_macro2::TokenStream,
}
impl DsRune for MyRune {
/* … inscribe_* methods append to self.out via quote! { … } … */
fn seal(self) -> proc_macro2::TokenStream {
self.out
}
}
seal taking self by value is deliberate — it makes the finalisation
single-shot. A rune that wants to inspect or post-process its accumulated
state runs that logic inside seal.
The name “seal” is the trait method, not Rust’s sealed-trait pattern. Same word, unrelated meaning.
The parent-context idiom
Backends commonly need to know which widget the current child is being
spawned under. The convention DefaultRune uses — and the convention
real ECS-shaped runes lean on — is save → set → recurse → restore:
fn inscribe_widget(&mut self, name: &syn::Ident, /* … */ children: &[DsTreeRef]) {
let name_string = name.to_string(); // 1. syn::Ident → String
let prev_parent = self.parent_name.clone(); // 2. save the current parent
self.parent_name = name_string; // 3. become the parent for this subtree
/* … emit code referring to `self.parent_name` … */
for child in children { // 4. children see *me* as parent
decipher(child, self);
}
self.parent_name = prev_parent; // 5. restore so my next sibling sees the right parent
}
The shape threads parent identity through arbitrary nesting without touching globals.
Worked example: DefaultRune
The bundled reference rune lives in
crates/xrune/src/default_rune.rs
and is the cleanest existing implementation of the seven methods. Read
it end-to-end — every inscribe handler is a few lines, the parent
push/pop idiom is in plain sight, and seal returns the accumulated
println!-shaped TokenStream.
There are two identical copies of DefaultRune in the repo:
xrune::default_rune::DefaultRune— public, documented, what you read when starting your own rune.- A private copy inside
xrune-incant— whatui! { … }actually expands against.
Why two? Because xrune-incant is a proc-macro crate, and Rust
forbids any non-macro item it exposes from being imported by a
downstream crate. Even if its DefaultRune were marked pub,
xrune::default_rune::DefaultRune = xrune_incant::DefaultRune is
rejected by the compiler:
error[E0432]: unresolved import `xrune_incant::DefaultRune`
no `DefaultRune` in the root
So xrune-incant keeps a private copy for its own ui! expansion, and
xrune writes a separate, public, copy-able-from copy in its
default_rune module for readers. Both copies are byte-for-byte the
same logic; only their visibility differs.
(A theoretical fix would be to sink default_rune into xrune-nexus
and have both crates import it, but that drags backend code — and the
quote dependency it brings — into a core that is deliberately kept to
just AST + DsRune trait + decipher.)
What ui! { … } actually does
The proc-macro is not an extension point. Its body, in full:
#[proc_macro]
pub fn ui(input: TokenStream) -> TokenStream {
let root = parse_macro_input!(input as DsRoot);
let mut rune = DefaultRune::new(); // ← hard-coded
rune.inscribe_root(&root.get_parent());
decipher(&root.get_content(), &mut rune);
TokenStream::from(rune.seal())
}
That DefaultRune::new() is literally hard-wired. There is no
way for the caller of ui! to swap it for something else. So when
you write ui! { … } in your application, what gets pasted back into
your source is the private println-shaped TokenStream DefaultRune
emits. Nothing more.
Concretely: ui! is a demonstration that the parser → decipher →
seal pipeline works end-to-end. It is not the entry point for a
real backend.
Hosting xrune in your own crate
For a real backend you don’t call ui!. You build your own
proc-macro crate, paste the same five-line hosting boilerplate, and
swap in your own rune. The shape:
my-host-incant/Cargo.toml
[package]
name = "my-host-incant"
edition = "2024"
[lib]
proc-macro = true
[dependencies]
xrune = "1.5"
syn = "2"
proc-macro2 = "1"
quote = "1"
xrune is the umbrella crate that re-exports xrune-nexus (the parser,
the DsRune trait, the decipher walker) and exposes
xrune::default_rune::DefaultRune as a copy-able reference rune. You
could depend on xrune-nexus directly to avoid the umbrella, but
xrune gives you shorter paths and lets you reach the public
DefaultRune while you’re prototyping.
my-host-incant/src/lib.rs
use proc_macro::TokenStream;
use syn::parse_macro_input;
use xrune::ds_node::DsRoot;
use xrune::ds_rune::DsRune;
use xrune::ds_rune::decipher::decipher;
mod my_rune;
use my_rune::MyRune;
#[proc_macro]
pub fn my_ui(input: TokenStream) -> TokenStream {
let root = parse_macro_input!(input as DsRoot);
let mut rune = MyRune::new();
rune.inscribe_root(&root.get_parent());
decipher(&root.get_content(), &mut rune);
TokenStream::from(rune.seal())
}
Pick your own macro name (my_ui, bevy_ui, whatever). Inside
my_rune.rs you implement the seven DsRune methods, emitting the
real spawn / render / whatever code your host actually needs. While
prototyping you can swap MyRune::new() for
xrune::default_rune::DefaultRune::new() to get the println trace
through your own macro and verify the wiring.
Downstream users then write:
use my_host_incant::my_ui;
my_ui! {
:(
parent: world
:)
/* … exact same casting syntax xrune accepts … */
}
Same DSL, your code-gen.
The xrune-fmt formatter is a sibling consumer that goes through the
same parser but doesn’t implement DsRune — it walks DsTree
directly to re-print. That’s the third shape of consumer (offline
tool), if your goal is analysis or reformatting rather than emitting
runtime code.
Where to go next
- Compare
DefaultRunewith the formatter in The Scribe — both walk the same tree shape, but only one implementsDsRune. The contrast is informative. - Read The Codex of Changes for the cross-version drift of the trait surface.
The Scribe
Drafting in progress. The English version of this chapter has not landed yet. Track issue #docs for the call to scribes.
The Workshop
Drafting in progress. The English version of this chapter has not landed yet. Track issue #docs for the call to scribes.
The Codex of Changes
Drafting in progress. The English version of this chapter has not landed yet. Track issue #docs for the call to scribes.
Comparisons
Drafting in progress. The English version of this chapter has not landed yet. Track issue #docs for the call to scribes.
Appendix · The Lexicon
This is the contract between xrune’s medieval vocabulary and ordinary compiler/CS terms. Every chapter reaches back here when the metaphor risks drifting from the engineering meaning.
Naming compact
| xrune term | Plain meaning |
|---|---|
| sigil | A derive macro (xrune-sigil). The DsRef derive mints {Name}Ref newtypes around Rc<RefCell<Name>>. |
| nexus | The core crate (xrune-nexus) — AST node types, the DsRune trait, and the decipher walker. The hub everything else binds to. |
| incant | The proc-macro crate (xrune-incant) that exposes ui! { … }. The act of invoking the DSL. |
| rune | A backend implementation of the DsRune trait — turns the parsed tree into emitted code. The DSL is one casting; runes are many translations. |
| decipher | The free function xrune::ds_rune::decipher::decipher(tree, &mut rune) that walks a DsTree and dispatches one inscribe call per node. |
| inscribe | One method on DsRune — inscribe_root / inscribe_widget / inscribe_if / inscribe_iter / inscribe_niche / inscribe_match. Each receives a node and accumulates output into the rune. |
| seal | The trait method seal(self) -> TokenStream. Consumes the rune at the end of decipher to produce the final emitted code. Not Rust’s “sealed trait” pattern — same name, unrelated meaning. |
| enchant | A bracketed expression list [expr, expr, …] attached to a widget. Arbitrary data the rune can attach to a node — typically ECS components or attached state. |
| niche | A @name { … } node. An anchor / slot / named region routed by the host rune (e.g. portals, named ports). |
| walk … with … | Iteration. walk iterable with var { … } is xrune’s for loop. |
| on | An event handler clause attached to a widget — on EventKind { … } (body form) or on EventKind(cb) (callback form). |
| scribe | The formatter binary xrune-fmt — re-renders DSL inside ui! { … } blocks. |
| grimoire | This documentation site. |
| codex | The version history (CHANGELOG). |
DSL casting compact
| Form | Reads as |
|---|---|
:( ... :) (each attr on its own line) | Context area. parent is required; other keys are rune-defined (world, theme, …). |
Widget (k: v) { … } | A widget node with named attrs and children. |
Widget (Text("hi")) | A widget with a positional attr. No body required. |
Widget (k: v) [Comp{…}, Tag] { … } | Same, with enchants between attrs and body. |
if expr { … } | Conditional render. |
walk it with x { … } | Iteration. |
@slot { … } | Niche (named anchor). |
match e { Pat => { … } … } | Pattern match across sub-trees. |
Widget () {} on Tap { fire() } | Form B: on after the body, attaches to the preceding sibling. |
Widget () on Tap { … } { … } | Form C: on between attrs and body, attaches to this widget. |
on Tap(cb) | Callback form. cb is the trailing arg; rune decides what it means. |
Public API index
The shape every reader needs once:
| Crate | Public surface |
|---|---|
xrune-sigil | #[derive(DsRef)] |
xrune-nexus | ds_node::* (the Ds* AST types) · ds_rune::DsRune trait · ds_rune::decipher::decipher · pub use xrune_sigil::DsRef |
xrune-incant | #[proc_macro] pub fn ui |
xrune | pub mod default_rune; · pub use xrune_incant::ui; · pub use xrune_nexus::*; |
xrune-fmt | xrune-fmt <file.rs> [--check] (binary) |