Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

序章 · 这本魔典是什么

xrune 是一台符文刻录引擎

你递给它一段咒文(写在 ui! { … } 里的声明式 DSL),它在编译期解读这串咒文的形状,把它转写成符文,再让你绑定的符文师(rune,即一个 DsRune 实现)把这些符文刻录到具体的代码上。最后封印(seal)一道,整段咒文凝结为一段可被宿主直接吸收的 TokenStream

这套切分就是整个设计的全部。同一段咒文,可以被刻录成 ECS spawn 调用,可以被刻录成渲染树构建器,可以被刻录成调试用的回响打印,也可以经由格式化器原样回环。决定它变成什么的不是咒文本身,而是你绑了哪一位符文师。

咒文不知道「部件」是什么。咒文只认形态。

何时召请 xrune

  • 宿主已有自己的组件、状态、渲染机关,要在上层叠一层好用的咒文,但不愿把咒文跟某种类型系统绑死。
  • 多位符文师需要共用同一套咒文(譬如:一位 ECS 运行时符文师 + 一位誊章符文师 + 一位静态分析符文师,三者同念一段咒)。
  • 渲染目标尚未敲定,但表层咒文先要立起来。

如果你的 DSL 需要在编译期就对部件名、属性键作类型检查,请改投 typed-builder 风格的咒法。xrune 把所有语义全数交托给符文师。

五位符文师与一座中枢

子典名号司掌
xrune入口卷轴。读者只需直接召请这一卷,它会把 xrune-nexusui! 两道咒符一并取出。
xrune-nexus中枢AST 节点(Ds* 一族)、DsRune 师契、decipher 遍历器,全在此卷。
xrune-incant咒坛ui! { … } 这道过程宏咒符的本体。
xrune-sigil印玺DsRef derive 宏,为 AST 锻造 Rc<RefCell<>> 的引用印记。
xrune-fmt誊章CLI 工具,把 ui! { … } 整段经真实的咒文 parser 来回誊写一次,确保形态无走样。

五卷同住一座 cargo workspace,版本同行同步:每次发版,五卷一齐推到同一道 X.Y.Z 版号。

命名契约

xrune 的词系偏中世纪魔法,因为架构本身就长这副样子:一咒多译。读者第一次撞见某个陌生词,去 附录 · 术语对照 查一次,往后应当不必再翻字典。最短摘要如下:

  • rune(符文师):一个 DsRune 实现,亦即一种后端。
  • decipher(译咒):遍历函数,遍历 AST,把每个节点喂给符文师的 inscribe(刻录) 方法。
  • inscribe(刻录):符文师在每个节点上落笔的动作。
  • seal(封印):遍历走完,符文师将累积之物收束为最终发出的 TokenStream

整个概念面就这些。其余诸词,sigil(印记)、niche(壁龛)、enchant(附魔)、walk(巡历)、on(事件咒符),都是咒文形态的具体一支,后续章节逐个揭。

这本魔典如何编排

  • 第一卷 · 初次施咒cargo new 起,直到看见 decipher 真正跑起来,并罗列每一种咒文形态。
  • 第二卷 · 内里机关 解读 AST 节点,并教你亲手锻造一位自己的符文师。
  • 第三卷 · 外用之器 讲誊章与工坊(workspace)本身。
  • 第四卷 · 流变与典故 记跨版本的形态演化,以及 xrune 与相邻 DSL 思路的对照。

附录 是逐词对照的术语表与公开 API 索引。

体例约定

  • 代码块默认皆为可 copy-paste 即跑的真实 Rust,例外会就地标注。少数仅为演示 ui! parser 输出、不挂宿主环境的代码块,现场会标。
  • 类型名、错误消息、CLI 文本一律自源码引用,不作转译。

第一次咏唱

cargo new 到看见 decipher 真的跑出来:五分钟。

例子渲染 UI:这本书没有部件运行时。它产出的是过程宏展开后的 内置 DefaultRune 输出:一串 println!,把 parser 喂给符文师的每个 节点都打一遍。本章要学的就这些。真正干活的后端要到 绑定符文 一章才登场。

起步

cargo new hello-xrune
cd hello-xrune

Cargo.toml

[package]
name = "hello-xrune"
version = "0.1.0"
edition = "2024"

[dependencies]
xrune = "1.5"

最小一咒

src/main.rs

use xrune::ui;

fn app(parent: i32) {
ui! {
    :(
        parent: parent
    :)

    container (width: 100, height: 100) {}
}
}

fn main() {
    app(0);
}

⚠ ▶ 按钮把代码发到 play.rust-lang.org,但那里没有 xrune 这个 crate, 在线运行会失败。点眼睛(👁)图标切换显示完整代码,复制到本地 cargo new 项目里、Cargo.tomlxrune = "1.5",再 cargo run 才能跑通。

本地 cargo run 后,会看到 DefaultRune 的 trace:

inscribe_root: 0
inscribe_widget: container, attrs: [width: 100, height: 100], children: []

(具体字符串依版本略有差异,结构不会变。)

到此为止。parser 接受了 ui! 块,decipher 遍历器走遍了每个节点,内置 rune 把它看到的东西打了出来。除此之外什么都没发生。没有部件,也没有窗口 弹出。

稍大一点的咒

仓库里 examples/example0 是一个把第一阶段语法形态都用一遍的范例:

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() {}

从这一段读出来的事:

  • :( ... :) 块(parent: parent 单独占一行)是上下文区parent 是唯一必填键;符文师通过 DsRoot::get_parent() 取到它。
  • width: 100, height: 100 + A, color: "red":属性值是任意 syn::Expr100 + A 是一个 Rust 表达式,不是字符串。
  • text (content: "hello world") { picker (…) {} }:子节点嵌套。 parser 建一棵 DsTree 单元格树;rune 决定嵌套意味着什么
  • walk range(20) with i { … }:迭代。**range(20) 不是标准库 函数。**这个例子只能跑到过程宏展开那一步;展开后引用了 plain Rust 里不存在的符号。学语法这没问题,要跑到端到端的真实例子需要一个 真实的 rune(第二卷)。
  • if a == "1" { … }:条件。同理,a 在这里是个自由变量。

一段话讲清刚才发生了什么

ui! { … }xrune-incant crate 出的一个过程宏。展开时它把 token 流解析成 DsRoot(AST 根), 构造内置 DefaultRune,用上下文里的 parent 表达式调用 inscribe_root, 然后对子树跑 decipher。每个被访问的节点,widget / if / walk / @niche / match,触发 rune 的某一个 inscribe_* 方法。最后 rune 被 seal,它累积的 TokenStream 就成了宏的输出。对 DefaultRune 而言 这个输出是一串 println!,此例之所以不挂 UI 运行时也能 「跑」,正是此故。

接下来

咏唱语法

ui! { … } 宏接受的每一种形态。六纲:

  • 上下文契约:( … :) 上下文头,每段咒文皆以此起手。
  • 部件节点:语言的核心,具名或位置属性、括号可省、body 可省。
  • 附魔[expr, expr, …] 方括号块,把任意数据挂到部件上。
  • 控制流ifwalk … with …@nichematch
  • on 事件咒符:事件子句的两种形态(B 与 C)、带体或回调、限定或裸名、有参或无参。
  • 被拒形态:parser 拒收的形态及理由。

DSL 在 parse 期不带类型。部件名是任意 ident,属性值是任意 syn::Expr。一切语义,何为「部件」、哪些属性算合法、某个事件名指什么,皆系于你绑的符文师,不在 xrune 自身。

上下文契约

每段 ui! { … } 咒文皆以一道上下文头起手:

use xrune::ui;
fn app(parent_expr: i32) {
ui! {
    :(
        parent: parent_expr
    :)

    placeholder {}
}
}
fn main() {}

:(:) 是字面意义上的成对 token。内里写一项或多项属性,形态与 DSL 别处的 name: value 一致。每项属性独占一行。

parent 的含义

parent唯一必填的上下文键。缺它,parser 直接拒收:

Root node must have a parent

符文师通过 DsRoot::get_parent() 取出 parent 表达式。对 DefaultRune 来说,它就是 inscribe_root 收到的那个值;真实后端通常把它作为余下整段咒文的 spawn-under / mount-on 实体一路串下去。

其余键由符文师自行约定

get_context_attrs() 返回头里携带的全部属性。xrune 自身只消费 parent;其余键留给你解读:

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() {}

不认 world 的符文师就不读它。没有谁在编译期验过 theme 是不是真符号:那是符文师自己的事,要么在 inscribe_root 里查,要么在封印(seal)一道里查。

多行布局

上下文头永远跨多行,每项属性独占一行:

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() {}

把两条以上属性塞在同一行,parser 会抛错:

root header must be multi-line — put each context attr on its own line

属性之间的逗号可省,下面这一形态同样合法:

use xrune::ui;
fn run(parent: i32, world: &mut u32) {
ui! {
:(
    parent: parent,
    world: &mut world,
:)
placeholder {}
}
}
fn main() {}

源码出处

DsRoot::parsecrates/xrune_nexus/src/ds_node/ds_root.rs。上述行为由 crates/xrune_nexus/src/tests.rs 里的 root_header_* 用例验证。

部件节点

最常用的形态。parser 产出 DsWidget 节点,携带:

  • 一个名字(syn::Ident),
  • 属性表(Vec<DsAttr>),
  • 附魔(Vec<syn::Expr>),
  • 事件咒符(Vec<DsOn>),
  • 子节点(Vec<DsTreeRef>)。

后端通过 inscribe_widget(name, attrs, enchants, on_handlers, children) 一次取走全部。

全部形态

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 {
    /* 全形:名 + 具名属性 + body。 */
    container (width: 480, height: 320, color: "dark") {
        placeholder {}
    }

    /* 不带括号:零属性。 */
    container {}

    /* 不带 body:零子节点。 */
    header (height: 40, text: "Hello")

    /* 空 body 等同于不带 body。 */
    header (height: 40, text: "Hello") {}

    /* 位置属性(DsAttr.name = None)。 */
    text ("hello world")
    button (Text("Save"), Disabled)
}
}
}
fn main() {}

具名属性 vs 位置属性

DsAttrname: Option<syn::Ident>。parser 先尝试 name: value 形态,匹配不上则退化为裸表达式的位置属性。同一个部件里两种形态可混用,顺序保留:

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() 把属性名作为 Option<&str> 交给符文师匹配;位置属性返回 None

属性值是真实的 Rust

属性值是 syn::Expr。凡能解析为 Rust 表达式的都行:

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() {}

每个值怎么用,由符文师决定。没有任何编译期约束规定属性名必须对应某个类型。

子节点本身就是完整的树

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() {}

每个子节点本身就是一棵 DsTree,所以部件、ifwalk@nichematch 都能嵌进另一个部件的 body 里。组合没有任何语法限制:哪些组合确有意义,由符文师把关。

源码出处

crates/xrune_nexus/src/ds_node/ds_widget.rs。由 tests.rsparse_widget_* 用例验证。

附魔

附魔是部件后附的一段方括号表达式串,坐落于属性与 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() {}

附魔的值是任意 syn::Expr:通常是组件字面量、struct 构造、标记 tag。符文师通过 DsWidget::get_enchants() 取出,再决定怎么用(作为 ECS 组件 spawn、作为中间件挂上去、记到实体上)。

位置

顺序固定:name (attrs) [enchants] { children }name 之后的四部分各自可省:

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                                     /* 无括号、无附魔、无 body */
    foo (a: 1)                              /* 仅属性 */
    foo (a: 1) [Tag] {}                     /* 属性 + 附魔 + 空 body */
    foo [Tag1, Tag2] {}                     /* 无属性,仅附魔 */
    foo () [Tag] {}                         /* 等价:空属性表 + 附魔 */
}
}
}
fn main() {}

无论哪一槽省略与否,符文师收到的都是一个 Vec:省了就是空 vec。

用法形态

附魔故意不带类型,这正是它的用处:让用户把任何东西挂到节点上,而不必在语法里为它专门预留位置。一个典型的 ECS 风符文师会把每个附魔表达式转成一个组件,插到 spawn 出来的实体上:

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() {}

调试符文师 DefaultRune 直接 print 出来。xrune-fmt(誊章)则原样回吐。

源码出处

解析分支在 DsWidget::parseds_widget.rs)。inscribe_widget 带一道 enchants: &[syn::Expr] 形参,由符文师消费。

控制流

四种形态:ifwalk … with …@nichematch。四者层级与部件节点相同,部件能嵌的地方它们也能嵌。

if — 条件渲染

use xrune::ui;
fn app(parent: i32, show_footer: bool) {
ui! {
    :(
        parent: parent
    :)
if show_footer {
    footer (height: 20) {}
}
}
}
fn main() {}

条件是一个完整的 syn::Expr,解析时吞花括号(body 块是单独一对 { … })。body 必填:没 body 的 if 直接 parse 错。

符文师从 inscribe_if(condition, children) 看到它。DSL 层没有 else 分支;要分两路,写两道条件相反的 if,或借 match 分作两途。

walk … with … — 巡历

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() {}

读作:巡历 items.iter();每个值绑到子节点里的 item。可迭代是 syn::Expr,绑名是 syn::Ident,body 必填。

walkwith 是保留关键字:都不能拿来当部件名。

符文师从 inscribe_iter(iterable, variable, children) 看到它。

@niche — 具名锚位

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() {}

壁龛是 @ 起头的 ident 加一个 body。语义全归符文师:可以是 portal 插槽、具名区域、路由目标、模板空缺位。parser 只担保形态是 @name { children }

符文师从 inscribe_niche(name, children) 看到它。

match — 模式匹配

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() {}

每个 arm 携带自己的 syn::Pat 与一棵子节点树。模式支持 Pat::parse_multi_with_leading_vert 接受的全部:绑定、通配、| 备选、struct 解构。每个 arm 末尾的逗号可省。

符文师从 inscribe_match(scrutinee, arms) 看到它,自己负责走每个 arm 的 get_children()

body 一律必填

四种控制节点都要带花括号 body。没 body 的 ifwalk@nichematch 都是 parse 错:它们若没 body,就是空操作,parser 直接拒收。

源码出处

on 事件咒符

on EventKind 子句把事件处理器附着到部件上。

on 是保留关键字:已注册为 custom token,所以 on Foo 永不会被误认成名为 on 的部件。

形态 B — body 之后的修饰链

use xrune::ui;
fn save() {}
fn app(parent: i32) {
ui! {
    :(
        parent: parent
    :)
button (text: "Save") {} on Tap {
    save();
}
}
}
fn main() {}

嵌进某个 body 时,形态 B 附着到紧靠前的兄弟部件附着到父部件:

use xrune::ui;
fn save() {}
fn cancel() {}
fn app(parent: i32) {
ui! {
    :(
        parent: parent
    :)
column {
    button (text: "Save") {}
    on Tap { save(); }            /* 附到上面那个 button */

    button (text: "Cancel") {}
    on Tap { cancel(); }          /* 附到 cancel 那个 button */
}
}
}
fn main() {}

同一部件可以串多条形态 B:

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() {}

形态 C — 属性与 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() {}

同一部件可叠多条形态 C。末尾的 {} 可省,不写时部件就是没有子节点:

use xrune::ui;
fn fire() {}
fn app(parent: i32) {
ui! {
    :(
        parent: parent
    :)
view ()
    on Tap { fire(); }
}
}
fn main() {}

形态 B 与形态 C 的处理器都汇入同一个 Vec<DsOn>,由 DsWidget::get_on_handlers() 一次取出。同一个部件上两种形态混用合法。

带体形态 vs 回调形态

事件处理器可以带 body 块:

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() {}

也可以仅带末尾一个回调表达式、不带 body:

use xrune::ui;
fn callback() {}
fn app(parent: i32) {
ui! {
    :(
        parent: parent
    :)
button () {}
on Tap(callback)
on Tap(2, callback)
}
}
fn main() {}

带体形态下 DsOn::get_body() 返回 Some(&syn::Block);回调形态下返回 None,由符文师自行决定 get_args() 末尾那个元素的语义:通常的约定是「事件触发时所调起的表达式」。

子句若既无 body 又无 args,即为 parse 错。

限定的事件名

use xrune::ui;
struct Slider;
fn commit() {}
fn app(parent: i32) {
ui! {
    :(
        parent: parent
    :)
slider () {}
on Slider::ValueChanged { commit(); }
}
}
fn main() {}

get_qualifier() 返回 Some(Slider)get_name() 返回 ValueChanged。限定段只允许一段Foo::Bar::Baz 直接拒收。

参数

on EventKind(…) 圆括号里是逗号分隔的 syn::Expr 列表。符文师由 get_args() 取出。常见形态:

on Tap { … }                        /* args: [], body 在 */
on Tap(2) { … }                     /* args: [2], body 在 */
on Tap(cb)                          /* 回调形态,无 body */
on Tap(2, cb)                       /* count + 回调 */

子句 body 与 args 两者皆无时,即为 parse 错:每个 on 至少要带其中一种。

源码出处

ds_on.rstests.rs 里的 form_b_* / form_c_* 用例。这套形态由 xrune-fmt 的 formatter 来回誊写一遍验过:每次此处形态有改,同一 commit 必带 fmt 更新。

被拒形态

不是每一种看起来合理的形态都能 parse。下面的情形都是有意拒收:parser 宁可拒收一个有歧义的咒文,也不会替你猜。

上下文头

你写的为什么失败
:( :)(无 parentRoot node must have a parent
:( foo: 1 :)同上:缺 parent
:( parent: r world: w :)(多属性单行)属性 >1 时必须多行

没 body 的 if / walk / @niche / match

if cond                /* parse error: body required */
walk it with x         /* parse error */
@slot                  /* parse error */
match e                /* parse error */

控制节点没 body 等于空操作。parser 宁可直接拒绝,不会默许这种形同摆设的语法落地。

on 的几种被拒形态

被拒的写法原因
on Tap { … } 写在 root,前面没部件on 必须有一个兄弟部件可附着
on Tap call_me() {}处理器要么 body 块、要么 (args) 形态,不能裸调用
on Foo::Bar::Baz { … }限定段只允许一段
on Tap 既无 body 又无 args处理器至少要带其中一种

壁龛名是单 ident

@foo::bar { } 不会 parse:壁龛名是单一 syn::Identmatch 里的模式则用 Pat::parse_multi_with_leading_vert 解析,凡 syn 在普通 match arm 里接受的,这里都接受。

什么不是

部件无子节点合法,header (text: "x")header (text: "x") {} 解析结果相同。部件不带括号也合法,container {} 是零属性零子节点。这些形态都对,读者有时会误以为不行。

这些拒收用例藏在哪

每条 error case 在 crates/xrune_nexus/src/tests.rs 里都有一条单测。测试名以 error_* 起头:真正的 ui! 块 parse 失败、错误消息一时又看不出端倪时,那卷 tests.rs 是个好去处。

符文图谱

本卷胪列 decipher 遍历器交给符文师的诸般解析形态。下面每个类型都恰好是你 inscribe_* 方法收下的东西。写 inscribe 处理器时,对哪个字段拿不准,就翻这一章。

DsTree 的形态

parser 建出来的一切都是同一个类型:

pub struct DsTree {
    parent: Option<DsTreeRef>,
    node: DsNode,
    children: Vec<DsTreeRef>,
}
  • parent:parser 链树时设的,符文师极少直接读它。绑定符文 那一章里,用 push/pop 形态牵引 parent 身份的是符文师内部状态,不是这个字段。
  • node:当前是哪种咏唱节点,见下文 DsNode
  • children:子树。叶子形态为空;部件 body、控制 body、壁龛 body 非空。match arm 的子节点挂在 arm 上,不在这里。

DsTreeRefDsRefDsTree 锻造的产物:一个对 Rc<RefCell<DsTree>> 的 newtype。用 .borrow() / .borrow_mut() 跟普通 RefCell 一样借。引用计数的形态是给 parser 链 parent / children 用的,不用纠结 lifetime;inscribe 路径上一般不需要克隆或修改它。

借出 DsTree 后能读的东西:

方法返回用途
get_node()&DsNodepattern-match 出当前是哪种节点
get_children()&[DsTreeRef]递归调 decipher(child, self) 时遍历
set_parent(parent)()parser 用,符文师不调

DsNode,六种变体

pub enum DsNode {
    Root(syn::Expr),
    Widget(DsWidget),
    If(DsIf),
    Iter(DsIter),
    Niche(DsNiche),
    Match(DsMatch),
}

你几乎不会直接 match DsNode,因为 decipher 已经把每种变体派给对应的 inscribe_* 方法。变体名跟 trait 一一对应:

DsNode 变体派给到达符文师时长这样
Root(expr)inscribe_rootparent_expr: &syn::Expr
Widget(w)inscribe_widgetwidget 完整拆开成 5 个参数
If(node)inscribe_ifcondition: &syn::Expr + children
Iter(node)inscribe_iteriterable + variable + children
Niche(node)inscribe_nichename: &syn::Ident + children
Match(node)inscribe_matchscrutinee: &syn::Expr + arms: &[DsMatchArm]

没有 On 变体。on EventKind { … } 子句折进它附着的部件里,作为 on_handlers: &[DsOn] slice 落到 inscribe_widget,永不以独立节点出现。

peek 用的 DsNodeType 是 parser 内部的辅助 enum(Widget / If / Iter / Niche / Match,没有 Root)。后端见不到。

DsRoot,咏唱信封

pub struct DsRoot { /* private */ }

impl DsRoot {
    pub fn get_parent(&self) -> syn::Expr;
    pub fn get_content(&self) -> DsTreeRef;
    pub fn get_context_attrs(&self) -> &[DsAttr];
}

每段咏唱只碰一次 DsRoot,就在宿主宏入口处,调 inscribe_rootdecipher 之前:

let root: xrune::ds_node::DsRoot = syn::parse2(tokens)?;
rune.inscribe_root(&root.get_parent());
decipher(&root.get_content(), &mut rune);
  • get_parent():返回 :( … :) 头里 parent: 那个表达式的克隆,可直接 splice 进发出的代码。
  • get_content():返回咏唱的 body,那棵 decipher 真正会走的 DsTreeRef
  • get_context_attrs():返回头里携带的全部属性,包括 parent 自己。当你的符文师定义了额外的上下文键(worldtheme…)、想在遍历前先读它们,用这个方法。

DsRoot 还实现了 Deref<Target = DsTreeRef>,但那是 parser 侧的便利;后端用显式 getter。

各节点类型

DsWidget

pub struct DsWidget { /* private */ }

impl DsWidget {
    pub fn get_name(&self) -> &syn::Ident;
    pub fn get_attrs(&self) -> &DsAttrs;
    pub fn get_enchants(&self) -> &[syn::Expr];
    pub fn get_on_handlers(&self) -> &[DsOn];
}

inscribe_widget 已经把这四个字段加上 children 拆好喂给你。DsWidget 自身是 parser 抓在手上的东西,只有当你手动遍历 DsNode(比如写 xrune-fmt 这种离线工具)时才需要它。

DsAttrDsAttrs

pub struct DsAttr {
    pub name: Option<syn::Ident>,
    pub value: syn::Expr,
}

impl DsAttr {
    pub fn name_str(&self) -> Option<String>;
}

pub struct DsAttrs {
    pub attrs: Vec<DsAttr>,
}
  • name: Option<syn::Ident>name: value 形态时为 Some,位置属性时为 Nonename_str() 是匹配友好的版本。
  • value: syn::Expr,用户写的原样。像匹配任何 syn::Expr 一样匹配它,或用 quote! splice 进发出的代码。

DsOn(事件处理器)

pub struct DsOn { /* private */ }

impl DsOn {
    pub fn get_qualifier(&self) -> Option<&syn::Ident>;
    pub fn get_name(&self) -> &syn::Ident;
    pub fn get_args(&self) -> &[syn::Expr];
    pub fn get_body(&self) -> Option<&syn::Block>;
}
  • get_qualifier()on Slider::ValueChanged 时是 Some(Slider);裸 on Tap 时是 None
  • get_name():永远在场,Tap / ValueChanged 之类。
  • get_args()(…) 里的逗号分隔表达式列表。
  • get_body():带体形态 { … } 时是 Some;回调形态 on Tap(cb) 时是 None,这种情况下符文师通常把 get_args() 末尾的元素当作可调用对象。

DsIf

impl DsIf {
    pub fn get_condition(&self) -> &syn::Expr;
}

子节点来自外围 DsTreeget_children()

DsIterwalk … with …

impl DsIter {
    pub fn get_iterable(&self) -> &syn::Expr;
    pub fn get_variable(&self) -> &syn::Ident;
}

iterablewalk 之后那段;variablewith 之后的绑定。body 同样在外围 DsTree

DsNiche@name { … }

impl DsNiche {
    pub fn get_name(&self) -> &syn::Ident;
}

只允许单段 ident,@foo::bar 是 parse 错。

DsMatchDsMatchArm

impl DsMatch {
    pub fn get_scrutinee(&self) -> &syn::Expr;
    pub fn get_arms(&self) -> &[DsMatchArm];
}

impl DsMatchArm {
    pub fn get_pat(&self) -> &syn::Pat;
    pub fn get_children(&self) -> &[DsTreeRef];
}

DsMatch 是唯一一种子节点不在外围 DsTree的节点。它们按 arm 切分,每个 arm 自带 get_children()。正因如此,inscribe_match 收的是 arms: &[DsMatchArm],符文师写双层循环。例子见 绑定符文 § 在你自己 crate 里安奉 xrune

自定义关键字

walkwithonsyn::custom_keyword! 注册,不能拿来当部件名、属性名或任何其他 ident。parser 在 widget peek 之前先派发它们,所以 on Foo 不会被误认成名为 on 的部件。

不需要操心的几个

下面这些虽然 public,但只跟 parser 或 xrune 自身有关:

  • DsContext / DsContextRef,标了 #[allow(dead_code)],是辅助结构,不在 inscribe 路径上。
  • DsNodeIsMe,每个节点 parser 都要实现的 peek 协议;只有 DsNode::what_type() 调用它。
  • DsTreeRef 内里的 Rc<RefCell<DsTree>>,给 decipher 在多次借用之间共享子节点用。在符文师里通常不需要克隆或操纵这个 Rc

源码出处

上述类型全在 crates/xrune_nexus/src/ds_node/,每个类型一个文件:ds_root.rsds_widget.rsds_attr.rsds_on.rsds_if.rsds_iter.rsds_niche.rsds_match.rsnode_enum.rs。消费它们的 DsRune trait 在 crates/xrune_nexus/src/ds_rune/mod.rsdecipher 遍历器紧挨着它。

绑定符文

一位 符文师 即一个后端:DsRune trait 的一份实现。parser 把树交给你;符文师把树翻成最终发出的代码。

本章逐方法走完 trait,再以内置的 DefaultRune 作工坊范本细读。

师契

DsRune 声明 道方法。没有任何一道带默认实现:每一位具象的符文师都得给齐七道。

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;
}

decipher 怎么调它们

decipher(tree, &mut rune) 遍历 AST,把节点逐一派给 inscribe 方法。要害一句:

decipher 只对 root 自动下钻。 其余 inscribe 方法都只收一道 childreninscribe_match 收的是 arms,每个 arm 自带 children),自己负责往下递归

这是 xrune 最常见的坑。 如果你的 inscribe_widget 收下 children 之后decipher(child, self),那棵子树会被静默丢弃,不报错、不警告,只是输出里突然少了几层节点。所有非 root 的 inscribe 方法都得自己跑那个递归。

这意味着典型的 inscribe_widget 长这样:

fn inscribe_widget(
    &mut self,
    name: &syn::Ident,
    attrs: &[DsAttr],
    enchants: &[syn::Expr],
    on_handlers: &[DsOn],
    children: &[DsTreeRef],
) {
    /* … 为 `name` / `attrs` 等发出 widget 构造代码 … */

    for child in children {
        decipher(child, self);   // 不写这行 → 子节点全部丢失
    }

    /* … 子节点走完后的收尾 … */
}

inscribe_if / inscribe_iter / inscribe_niche 跟它形态相同:单层 for child in children

inscribe_match 比较特殊:trait 签名只收 arms: &[DsMatchArm]单独的 children slice。每个 arm 自带 get_children(),所以要写双层循环,外层走 arms,内层走每个 arm 的子树:

fn inscribe_match(&mut self, scrutinee: &syn::Expr, arms: &[DsMatchArm]) {
    /* … 发出 match 头 … */

    for arm in arms {
        /* … 发出本条 arm 的 pattern 头 … */
        for child in arm.get_children() {
            decipher(child, self);
        }
        /* … 发出本条 arm 的尾 … */
    }
}

深度由符文师驱动,decipher 一次只派一层。这是有意为之:把顺序(先发父再发子、父子交错、还是要等整棵子树看完才发)和作用域(递归前往栈里压一个 parent 符号、回来后弹掉)的全部权力都还给符文师。

封印一道

seal(self) -> TokenStream 在末尾按值消费符文师。一路 inscribe 累积进符文师内部,通常是一道 proc_macro2::TokenStream 字段,由 seal 一次还出去。

struct MyRune {
    out: proc_macro2::TokenStream,
}

impl DsRune for MyRune {
    /* … inscribe_* 方法靠 quote! { … } 把内容串进 self.out … */

    fn seal(self) -> proc_macro2::TokenStream {
        self.out
    }
}

seal 按值收 self 是有意的:让收尾这一步只发生一次。要做累积态的检查或后处理,就在 seal 内部跑。

这里的「seal」是 trait 方法名,跟 Rust 的 sealed-trait 习语 同名不同义。

父级上下文:保存→设当前→递归→还原

后端常常要知道当前子节点正被 spawn 到哪个部件之下。DefaultRune 用的、真实 ECS 风符文师也都靠这一招:

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. 保存当前 parent 身份
    self.parent_name = name_string;               // 3. 把当前部件设为新 parent

    /* … 这里发的代码可以引用 `self.parent_name` … */

    for child in children {                       // 4. 子节点们看到的 parent 就是当前 widget
        decipher(child, self);
    }

    self.parent_name = prev_parent;               // 5. 走完还原,让下一个兄弟看到的 parent 跟我同级
}

这个形态把 parent 身份贯穿任意层嵌套,不动用全局

工坊范本:DefaultRune

内置的参考符文师就在 crates/xrune/src/default_rune.rs,是七道方法最干净的现成实现。从头读到尾即可:每个 inscribe 处理器几行而已,parent 压入还原的形态一望即知,seal 还出累积好的 println!TokenStream

DefaultRune 在仓库里实际有两份内容相同的实现

  • xrune::default_rune::DefaultRune,公开、文档化,写自己符文师时照着抄的那一份。
  • 藏在 xrune-incant 内部的私本,ui! { … } 宏展开时实际跑的那一份。

为什么两份? 因为 xrune-incant 是 proc-macro crate,Rust 编译器有一条铁律:

proc-macro crate 对外只能导出 #[proc_macro] / #[proc_macro_derive] / #[proc_macro_attribute] 这三类宏函数。crate 里所有pub struct / pub fn / pub mod,对任何下游 crate 都不可见。

所以哪怕 xrune-incantDefaultRune 标了 pub,下游想写 pub use xrune_incant::DefaultRune; 编译器也会拒收:

error[E0432]: unresolved import `xrune_incant::DefaultRune`
  no `DefaultRune` in the root

xrune-incant 只能在内部留一份私本给自家 ui! expansion 用。xrune 拿不到那份,于是自己另写了一份独立的 default_rune 模块,作为对读者公开的“参考实现“。两份逻辑与输出逐字节一致,只差在可见性。

(理论上可以把 default_rune 沉到 xrune-nexus 让两边都 import,但那会把后端代码,以及它带来的 quote 依赖,拖进核心。xrune-nexus 要保持只有 AST、DsRune trait、decipher,不绑定任何具体后端。)

ui! { … } 实际做了什么

这个过程宏不是扩展点。它的函数体完整如下:

#[proc_macro]
pub fn ui(input: TokenStream) -> TokenStream {
    let root = parse_macro_input!(input as DsRoot);
    let mut rune = DefaultRune::new();        // ← 写死的
    rune.inscribe_root(&root.get_parent());
    decipher(&root.get_content(), &mut rune);
    TokenStream::from(rune.seal())
}

那行 DefaultRune::new() 是字面意义上的写死ui! 的调用者没有任何接口能换成别的符文师。所以你在自家代码里写 ui! { … },宏展开后塞回源码的,就是 DefaultRune 那份 println 形态的 TokenStream,仅此而已。

明白讲:ui! 是「parser → decipherseal 整条管子能跑通」的演示,不是真后端的入口。

在你自己 crate 里安奉 xrune

要做真后端,你不调 ui!。你新建自己的 proc-macro crate,把那五行宿主样板抄过去,把 DefaultRune 换成自己的符文师。形态如下:

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 是聚合入口 crate,把 xrune-nexus(parser、DsRune trait、decipher 遍历器)整个重导出(re-export),并公开 xrune::default_rune::DefaultRune 作为一份可照抄的参考符文师。绕开它、直接依赖 xrune-nexus 也行,但用 xrune 引入路径更短,写原型时还能顺手拿现成的 DefaultRune 顶上。

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())
}

宏起一个自己的名字my_uibevy_ui,随意)。my_rune.rs 里实现七道 DsRune 方法,发出你宿主真正需要的 spawn / render / 任何代码。开发期可以先把 MyRune::new() 换成 xrune::default_rune::DefaultRune::new(),让自己的宏先跑出 println trace,验证管子接好。

下游用户这样写:

use my_host_incant::my_ui;

my_ui! {
    :(
        parent: world
    :)
    /* … xrune 接受的同一套咏唱语法 … */
}

同一套 DSL,你的代码生成。

xrune-fmt(誊章)是同一类消费者的另一种形态:它走同一个 parser,但实现 DsRune,直接遍历 DsTree 重新打印。这是第三种形态的消费者(离线工具),如果你想做的是分析或重排,而不是发出运行时代码,可以走这条路。

接下来去哪

  • DefaultRune誊章 里的格式化器对照:两者走的是同一棵树,但只有一边实现 DsRune。差别处即学习处。
  • 流变志 记录 trait 在跨版本之间的形态流转。

誊章

xrune-fmt(誊章)是 ui! { … } 块的格式化器。一个 CLI 二进制,不是 lib 库,装一次,对准任何含咏唱宏的 .rs 文件。

cargo install xrune-fmt

xrune-fmt src/app.rs            # 原地重写
xrune-fmt src/app.rs --check    # 未格式化则退出码 1,文件不动

它做什么

对每段 ui! { … },誊章会:

  1. 用正则 ui!\s*\{ 找到宏,配对的 } 靠 brace 深度计数。
  2. 把里面的内容交给真正的 parserxrune-nexusDsRoot::parse,拿回一棵 DsTree
  3. 遍历这棵树,按统一缩进、换行、间距重发。
  4. 如果 parser 拒收,整段保持原样。誊章绝不默默改写自己读不懂的块。

它只动 ui! { … } 体内的内容。宏外面的代码按字节保留。

格式化规则

  • 上下文头始终多行,每属性独占一行,缩进比 ui! 大括号多一级。
  • 部件属性:单行能装下且 ≤ MAX_LINE_WIDTH = 100 时单行;否则每属性独占一行。原文已经多行的,即便重排后单行也能装下,誊章仍保持多行,作者意图优先于列宽。
  • 属性值、walk 可迭代、if/match scrutinee、on body 一律走 prettyplease,让嵌入的 Rust 表达式按规范形态渲染。
  • on EventKind 子句叠在属性与 body 之间,body 缩进比外层部件多一级。
  • 附魔坐落在属性与 body 之间,写在 [ … ],逗号分隔。

誊章能 round-trip 的形态,跟 xrune-nexus::tests 验过的一样齐全:位置属性、具名属性、无 body 的部件、壁龛、match arm、三种 on 形态(B、C、回调)。xrune-fmt 自己的测试集走遍 examples/example0/src/ui 每个 fixture,确认幂等。

为什么单独养一个跟随 parser 形态的消费者

誊章是同一棵 DsTree第二位消费者。第一位是你的符文师(DsRune 实现 + decipher)。誊章实现 DsRune,它手动遍历这棵树,因为它的目标是重发语法,不是把树翻成运行时代码。

这也是为什么誊章是语言改动的金丝雀:parser 多认一种新形态,誊章就得跟着把对应字段读出来;AST 长一个新节点,誊章就要加一个 arm。要往 xrune-nexus 加东西时,先看一眼誊章,掂量一下下游连带工作量。

源码出处

工坊

工作区怎么排列、cargo xtask 给维护者哪些手段。这一章是给贡献者看的。终端用户只需要 第一次咏唱绑定符文

五位 crate 加一名 xtask

xrune/
├─ crates/
│  ├─ xrune-sigil      ← derive 宏:`#[derive(DsRef)]`
│  ├─ xrune-nexus      ← AST + DsRune trait + decipher
│  ├─ xrune-incant     ← 过程宏:`ui! { … }`
│  ├─ xrune            ← 聚合入口:重导出 nexus + ui!,并放 default_rune
│  └─ xrune-fmt        ← CLI 二进制:誊章
├─ examples/
│  └─ example0         ← 经典 hello-world fixture
└─ xtask/              ← CI/build/test/lint/doctest/bump/publish/release

五位发版 crate 同住一座工作区,同步推一道版号。cargo install xrune 顺带把 xrune-incantxrune-nexus 间接拉进来。

xtask 标了 publish = false永不进 crates.io。它只为驱动工作区本身存在。

cargo xtask 命令面

cargo xtask <ci|build|test|lint|doctest|bump <level>|publish [--dry-run]|release>
  • ci:依次跑 build + test + lint + doctest.github/workflows/ci.yml 调的就是它。本地绿 = CI 绿。
  • buildcargo build --workspace
  • testcargo test --workspace
  • lintcargo +stable clippy --workspace -- -D warnings,再跑 cargo +stable fmt --all --check+stable toolchain 是为了跟 CI 对齐。
  • doctest:把 docs/src/docs/src-zh-CN/ 里所有 ```rust 块抽出来,倒进 docs/test/ 这个一次性测试 crate 里跑 cargo build --tests。文档跟实现脱节会被它当场抓住。
  • bump <major|minor|patch>:递归改写每个 Cargo.toml 的版本(workspace 那一处加上 [workspace.dependencies] 里五条 version = "=X.Y.Z" 锁版)。禁止手改版号。
  • publish [--dry-run]:按固定顺序推 crates.io:xrune-sigil → xrune-nexus → xrune-incant → xrune → xrune-fmt,每个 crate 之间 sleep 30 秒等 crates.io 索引刷新。已发布的会被跳过。别直接调它,走 release
  • release:发版的唯一通路。先要工作区干净,然后内部按顺序:push main → 通过 gh run list 等 CI 全绿(10 分钟超时)→ 打 vX.Y.Z 标签 → 推 tag → gh release create --generate-notes → 调 publish

工作区约定

  • [workspace.package].version 是版号唯一来源。[workspace.dependencies] 里五条 crate 各用 version = "=X.Y.Z" 精确锁版,确保五位永远同步推进。
  • Cargo.lock 已 gitignore,这是工作区有意为之,不是疏忽。
  • default-members = ["crates/*"],在仓库根 cargo build 只会构发版 crate,不动 xtaskexamples/
  • 整个工作区 edition = "2024"
  • resolver = "2",让 cargo 走现代 feature 统一规则。

源码出处

流变志

对版本史的指引,而不是把它复读一遍。完整 changelog 见 CHANGELOG.md;本章只挑那些会牵动已经写好的符文师的改动。

现行的形态

其他章节描述的那些形态,就是现在 crates.io 上发布的形态:DsRune trait 的七道方法、六个 DsNode 变体、三种 on 形态(B、C、回调)、附魔、壁龛、match arm、name: Option<syn::Ident> 这样的属性形态。照本文档写的代码,如今即可编过。

按接口面拆分的迁移笔记

下面每条都假定你手上是上一种形态、需要迁到现行形态。

on EventKind 处理器

更早的 parser 里有一种树级 DsOn 节点,跟 widget / if / iter / niche / match 同位。trait 里有对应的 inscribe_on 方法。

现在两者都没有。on 子句折进它附着的部件,作为 on_handlers: &[DsOn] 落到 inscribe_widget。没有 DsNode::On 变体,也没有 inscribe_on 方法。

如果你之前实现过 inscribe_on:删掉,把读 on-handlers 的逻辑搬到 inscribe_widget 内的 on_handlers slice。

DsOn::get_body()

之前直接返回 &syn::Block。现在返回 Option<&syn::Block>,因为 on EventKind(cb)(回调形态)不携带 body。

如果你之前无条件读 body,改成处理 None 分支,通常是把 get_args() 末尾的元素当作回调表达式来读。

DsAttr::name

之前是非可选 syn::Ident。现在是 Option<syn::Ident>,因为位置属性(text("hello"))不带名字。

如果你直接读 attr.name,改成 match Option。匹配友好的版本是 name_str() -> Option<String>

壁龛与 match 节点

@name { … }match expr { … } 现已是 trait 的一部分。如果你的 DsRune 实现写得早于它们,编译器会要求 inscribe_nicheinscribe_match,trait 没有默认实现。

已删除

下面这些不再属于这门语言。在范例代码里看到,说明那段代码出自更早的版本。

已删除替代
DsRune::inscribe_on 方法inscribe_widget 上的 on_handlers: &[DsOn]
DsNode::On 变体折进部件
DsTreeToTokens traitDsRune codegen 接口
ds_traverse 模块xrune::ds_rune::decipher::decipher
crate 名 xwrapup / xrune_derive / xrune_parser / xrune_macrosxrune-sigil / xrune-nexus / xrune-incant / xrune

源码出处

同道比较

xrune 立身于一片 Rust DSL / UI 宏林立的同道之间。要快速摸清它在哪一档:

xrunetyped-builder 风 DSL(leptos::view! / dioxus::rsx! / yew::html!macro-by-example 类(maud / html!
校验时机符文师阶段。DSL 自身接受任意 ident 作部件,任意 syn::Expr 作属性值。parse 时。部件名是具体类型,属性 map 到 typed setter。多在编译期,但绑死在固定语法(HTML)。
部件词汇开放。你的符文师认什么、就有什么。封闭。host crate 的组件库定义有什么。封闭。绑死单一输出(HTML)。
一套语法的后端数多。一套 DSL → 多个符文师(renderer / ECS / 格式化器 / 分析器…)。一。DSL 与框架捆绑。一。
你写代码生成的位置在自己 crate 里写 DsRune 实现。框架内置。在宏内部。
属性值的类型检查parse 期没有;由符文师定。强;属性变成 typed builder 调用。限于宏 pattern-match 能做的。
迭代/条件/match一等公民 DSL 节点(walk / if / match)。通常委托给宏内的 inline Rust 表达式。inline Rust。

何时召请 xrune

  • 宿主已有自己的组件 / 状态 / 渲染机关,想在上层加一层咏唱,但不愿把咏唱绑死在那套类型上。
  • 渲染目标尚未敲定,但表层咏唱要先立。
  • 一套语法要被多种消费者共享,比如一位 ECS 运行时符文师 + 一位誊章符文师 + 一位静态分析符文师。

何时另请高明

  • 你想要部件名与属性值在编译期就强类型化,去 typed-builder 风格的 DSL(leptosdioxusyew、或者直接 typed-builder)。xrune 故意把所有类型层校验扔给符文师。
  • 你的输出就是 HTML,对可插拔后端没兴趣,maud / html! 在那个目标上更紧凑。
  • 你想要现成的部件库,xrune 一个不带。野外的符文师都自带组件。

一幅约略的心象

typed-builder 风 DSL 把语法面焊死在一个后端上,换来安全。xrune 反过来,它焊死的是咏唱形态(一个 parser、一棵树、一种遍历),后端可以是任何能塞进 DsRune 七方法 trait 的东西。

两者都是合理的取舍。当「部件到底干什么」本身就是个流动问题时,xrune 是合手的工具。

附录 · 术语对照

这是 xrune 的中世纪词系与寻常编译器/计算机术语之间的契约。后续诸章一旦有「比喻像是飘离工程含义」之险,便回此处校核。

命名契约

原词译名工程含义
sigil印玺一个 derive 宏 crate(xrune-sigil)。DsRef derive 为目标 struct 锻造 {Name}Ref newtype,内里是 Rc<RefCell<Name>>
nexus中枢核心 crate(xrune-nexus)。AST 节点类型、DsRune trait、decipher 遍历器,皆在此。其余 crate 都向这里收束。
incant咒坛过程宏 crate(xrune-incant),导出 ui! { … }。咏唱即调起 DSL 的动作。
rune符文师DsRune trait 的一个后端实现。把解析出的树翻成最终代码。咒文只一道;符文师可有许多种译法。
decipher译咒自由函数 xrune::ds_rune::decipher::decipher(tree, &mut rune)。遍历 DsTree,对每个节点调一次符文师的 inscribe 方法。
inscribe刻录DsRune 上的方法之一:inscribe_root / inscribe_widget / inscribe_if / inscribe_iter / inscribe_niche / inscribe_match。每个收下一个节点,将产出累积进符文师内部。
seal封印trait 方法 seal(self) -> TokenStream。译咒走完,封印一道,把符文师内累积之物收束为最终发出的代码。注意:不是 Rust 的「sealed trait」习语,同名不同义。
enchant附魔部件后附的方括号表达式串 [expr, expr, …]。任意数据,由符文师挂到节点上:典型用法是 ECS 组件或附着状态。
niche壁龛@name { … } 节点。一处具名锚位 / 插槽 / 命名区域,由宿主符文师路由(如 portal、命名端口)。
walk … with …巡历 … 取 …迭代。walk iterable with var { … } 是 xrune 的 for 循环。
on事件咒符部件后附的事件处理子句:on EventKind { … }(带体形态)或 on EventKind(cb)(回调形态)。
scribe誊章格式化器二进制 xrune-fmt。把 ui! { … } 块经真实 parser 来回誊写一次。
grimoire魔典你正在读的这本文档站。
codex流变志版本流变史(CHANGELOG)。

DSL 咏唱形态摘要

形态名号解读
:( ... :)(每个属性独占一行)上下文区parent 必填;其余键由符文师定义(worldtheme…)。
Widget (k: v) { … }部件・带属性带体带具名属性与子节点的部件。
Widget (Text("hi"))部件・位置属性带位置属性的部件。可不带 body。
Widget (k: v) [Comp{…}, Tag] { … }部件・附魔同上,附魔写在属性与 body 之间。
if expr { … }条件条件渲染。
walk it with x { … }巡历迭代。
@slot { … }壁龛具名锚位。
match e { Pat => { … } … }模式匹配跨子树的模式匹配。
Widget () {} on Tap { fire() }事件咒符・形态 Bon 在 body 之后,附着到前一个兄弟部件。
Widget () on Tap { … } { … }事件咒符・形态 Con 在属性与 body 之间,附着到本部件。
on Tap(cb)事件咒符・回调回调形态。cb 是末尾参数,由符文师决定语义。

公开 API 索引

每位读者只需扫一次的最小面:

Crate名号公开面
xrune-sigil印玺#[derive(DsRef)]
xrune-nexus中枢ds_node::*Ds* AST 类型族)· 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-fmt誊章xrune-fmt <file.rs> [--check](二进制)