Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

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

VolumeSuffixOffice
xruneThe opening scroll. The reader summons only this; it draws xrune-nexus and the ui! rite from beneath.
xrune-nexusnexusThe hub. AST nodes (Ds*), the DsRune covenant, the decipher walk — all kept here.
xrune-incantincantThe speaking-stone. The proc-macro that is ui! { … }.
xrune-sigilsigilThe sigil-forge. The DsRef derive macro that mints Rc<RefCell<>> reference-sigils for the AST.
xrune-fmtfmtThe 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 DsRune trait.
  • 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 new to seeing decipher actually 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 xrune crate, so the run will fail there. Use the eye ( 👁 ) toggle to reveal the full program, copy it into a local cargo new project with xrune = "1.5" in Cargo.toml, and cargo 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 (with parent: parent on its own line) is the context area. parent is the only required key; the rune sees it via DsRoot::get_parent().
  • width: 100, height: 100 + A, color: "red" — attribute values are arbitrary syn::Expr. 100 + A is a Rust expression, not a string.
  • text (content: "hello world") { picker (…) {} } — children nest. The parser builds a tree of DsTree cells; 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, a is 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 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 Flowif, walk … with …, @niche, match.
  • The on Handlers — 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

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 wroteWhy 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

RejectedReason
on Tap { … } at the root, no preceding widgeton 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 argsA 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:

decipher only auto-recurses into the root. Every other inscribe method receives a children slice (or, for inscribe_match, an arms slice where each arm carries its own children) and is responsible for recursing itself.

This is xrune’s most common foot-gun. If your inscribe_widget takes children and forgets to call decipher(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 — what ui! { … } 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 → decipherseal 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 DefaultRune with the formatter in The Scribe — both walk the same tree shape, but only one implements DsRune. 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 termPlain meaning
sigilA derive macro (xrune-sigil). The DsRef derive mints {Name}Ref newtypes around Rc<RefCell<Name>>.
nexusThe core crate (xrune-nexus) — AST node types, the DsRune trait, and the decipher walker. The hub everything else binds to.
incantThe proc-macro crate (xrune-incant) that exposes ui! { … }. The act of invoking the DSL.
runeA backend implementation of the DsRune trait — turns the parsed tree into emitted code. The DSL is one casting; runes are many translations.
decipherThe free function xrune::ds_rune::decipher::decipher(tree, &mut rune) that walks a DsTree and dispatches one inscribe call per node.
inscribeOne method on DsRuneinscribe_root / inscribe_widget / inscribe_if / inscribe_iter / inscribe_niche / inscribe_match. Each receives a node and accumulates output into the rune.
sealThe 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.
enchantA bracketed expression list [expr, expr, …] attached to a widget. Arbitrary data the rune can attach to a node — typically ECS components or attached state.
nicheA @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.
onAn event handler clause attached to a widget — on EventKind { … } (body form) or on EventKind(cb) (callback form).
scribeThe formatter binary xrune-fmt — re-renders DSL inside ui! { … } blocks.
grimoireThis documentation site.
codexThe version history (CHANGELOG).

DSL casting compact

FormReads 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:

CratePublic surface
xrune-sigil#[derive(DsRef)]
xrune-nexusds_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
xrunepub mod default_rune; · pub use xrune_incant::ui; · pub use xrune_nexus::*;
xrune-fmtxrune-fmt <file.rs> [--check] (binary)