序章 · 这本魔典是什么
xrune 是一台符文刻录引擎。
你递给它一段咒文(写在 ui! { … } 里的声明式 DSL),它在编译期解读这串咒文的形状,把它转写成符文,再让你绑定的符文师(rune,即一个 DsRune 实现)把这些符文刻录到具体的代码上。最后封印(seal)一道,整段咒文凝结为一段可被宿主直接吸收的 TokenStream。
这套切分就是整个设计的全部。同一段咒文,可以被刻录成 ECS spawn 调用,可以被刻录成渲染树构建器,可以被刻录成调试用的回响打印,也可以经由格式化器原样回环。决定它变成什么的不是咒文本身,而是你绑了哪一位符文师。
咒文不知道「部件」是什么。咒文只认形态。
何时召请 xrune
- 宿主已有自己的组件、状态、渲染机关,要在上层叠一层好用的咒文,但不愿把咒文跟某种类型系统绑死。
- 多位符文师需要共用同一套咒文(譬如:一位 ECS 运行时符文师 + 一位誊章符文师 + 一位静态分析符文师,三者同念一段咒)。
- 渲染目标尚未敲定,但表层咒文先要立起来。
如果你的 DSL 需要在编译期就对部件名、属性键作类型检查,请改投 typed-builder 风格的咒法。xrune 把所有语义全数交托给符文师。
五位符文师与一座中枢
| 子典 | 名号 | 司掌 |
|---|---|---|
xrune | — | 入口卷轴。读者只需直接召请这一卷,它会把 xrune-nexus 与 ui! 两道咒符一并取出。 |
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.toml加xrune = "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::Expr。100 + 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, …]方括号块,把任意数据挂到部件上。 - 控制流:
if、walk … with …、@niche、match。 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::parse 见 crates/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 位置属性
DsAttr 带 name: 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,所以部件、if、walk、@niche、match 都能嵌进另一个部件的 body 里。组合没有任何语法限制:哪些组合确有意义,由符文师把关。
源码出处
crates/xrune_nexus/src/ds_node/ds_widget.rs。由 tests.rs 的 parse_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::parse(ds_widget.rs)。inscribe_widget 带一道 enchants: &[syn::Expr] 形参,由符文师消费。
控制流
四种形态:if、walk … with …、@niche、match。四者层级与部件节点相同,部件能嵌的地方它们也能嵌。
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 必填。
walk 与 with 是保留关键字:都不能拿来当部件名。
符文师从 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 的 if、walk、@niche、match 都是 parse 错:它们若没 body,就是空操作,parser 直接拒收。
源码出处
if→ds_if.rswalk … with …→ds_iter.rs@niche→ds_niche.rsmatch→ds_match.rs
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.rs 与 tests.rs 里的 form_b_* / form_c_* 用例。这套形态由 xrune-fmt 的 formatter 来回誊写一遍验过:每次此处形态有改,同一 commit 必带 fmt 更新。
被拒形态
不是每一种看起来合理的形态都能 parse。下面的情形都是有意拒收:parser 宁可拒收一个有歧义的咒文,也不会替你猜。
上下文头
| 你写的 | 为什么失败 |
|---|---|
:( :)(无 parent) | Root 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::Ident。match 里的模式则用 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 上,不在这里。
DsTreeRef 是 DsRef 为 DsTree 锻造的产物:一个对 Rc<RefCell<DsTree>> 的 newtype。用 .borrow() / .borrow_mut() 跟普通 RefCell 一样借。引用计数的形态是给 parser 链 parent / children 用的,不用纠结 lifetime;inscribe 路径上一般不需要克隆或修改它。
借出 DsTree 后能读的东西:
| 方法 | 返回 | 用途 |
|---|---|---|
get_node() | &DsNode | pattern-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_root | parent_expr: &syn::Expr |
Widget(w) | inscribe_widget | widget 完整拆开成 5 个参数 |
If(node) | inscribe_if | condition: &syn::Expr + children |
Iter(node) | inscribe_iter | iterable + variable + children |
Niche(node) | inscribe_niche | name: &syn::Ident + children |
Match(node) | inscribe_match | scrutinee: &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_root 与 decipher 之前:
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自己。当你的符文师定义了额外的上下文键(world、theme…)、想在遍历前先读它们,用这个方法。
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 这种离线工具)时才需要它。
DsAttr 与 DsAttrs
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,位置属性时为None。name_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;
}
子节点来自外围 DsTree 的 get_children()。
DsIter(walk … with …)
impl DsIter {
pub fn get_iterable(&self) -> &syn::Expr;
pub fn get_variable(&self) -> &syn::Ident;
}
iterable 是 walk 之后那段;variable 是 with 之后的绑定。body 同样在外围 DsTree。
DsNiche(@name { … })
impl DsNiche {
pub fn get_name(&self) -> &syn::Ident;
}
只允许单段 ident,@foo::bar 是 parse 错。
DsMatch 与 DsMatchArm
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。
自定义关键字
walk、with、on 由 syn::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.rs、ds_widget.rs、ds_attr.rs、ds_on.rs、ds_if.rs、ds_iter.rs、ds_niche.rs、ds_match.rs、node_enum.rs。消费它们的 DsRune trait 在 crates/xrune_nexus/src/ds_rune/mod.rs;decipher 遍历器紧挨着它。
绑定符文
一位 符文师 即一个后端: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 方法都只收一道children(inscribe_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-incant 的 DefaultRune 标了 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 → decipher → seal 整条管子能跑通」的演示,不是真后端的入口。
在你自己 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_ui、bevy_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 重新打印。这是第三种形态的消费者(离线工具),如果你想做的是分析或重排,而不是发出运行时代码,可以走这条路。
接下来去哪
誊章
xrune-fmt(誊章)是 ui! { … } 块的格式化器。一个 CLI 二进制,不是 lib 库,装一次,对准任何含咏唱宏的 .rs 文件。
cargo install xrune-fmt
xrune-fmt src/app.rs # 原地重写
xrune-fmt src/app.rs --check # 未格式化则退出码 1,文件不动
它做什么
对每段 ui! { … },誊章会:
- 用正则
ui!\s*\{找到宏,配对的}靠 brace 深度计数。 - 把里面的内容交给真正的 parser,
xrune-nexus的DsRoot::parse,拿回一棵DsTree。 - 遍历这棵树,按统一缩进、换行、间距重发。
- 如果 parser 拒收,整段保持原样。誊章绝不默默改写自己读不懂的块。
它只动 ui! { … } 体内的内容。宏外面的代码按字节保留。
格式化规则
- 上下文头始终多行,每属性独占一行,缩进比
ui!大括号多一级。 - 部件属性:单行能装下且 ≤
MAX_LINE_WIDTH = 100时单行;否则每属性独占一行。原文已经多行的,即便重排后单行也能装下,誊章仍保持多行,作者意图优先于列宽。 - 属性值、
walk可迭代、if/matchscrutinee、onbody 一律走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 加东西时,先看一眼誊章,掂量一下下游连带工作量。
源码出处
- CLI + ui! 块抽取:
crates/xrune_fmt/src/main.rs - 树遍历 + 重发:
crates/xrune_fmt/src/formatter.rs
工坊
工作区怎么排列、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-incant 与 xrune-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 绿。build:cargo build --workspace。test:cargo test --workspace。lint:cargo +stable clippy --workspace -- -D warnings,再跑cargo +stable fmt --all --check。+stabletoolchain 是为了跟 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:发版的唯一通路。先要工作区干净,然后内部按顺序:pushmain→ 通过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,不动xtask或examples/。- 整个工作区
edition = "2024"。 resolver = "2",让 cargo 走现代 feature 统一规则。
源码出处
- 工作区 manifest:
Cargo.toml - xtask:
xtask/src/main.rs - CI:
.github/workflows/ci.yml
流变志
对版本史的指引,而不是把它复读一遍。完整 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_niche 与 inscribe_match,trait 没有默认实现。
已删除
下面这些不再属于这门语言。在范例代码里看到,说明那段代码出自更早的版本。
| 已删除 | 替代 |
|---|---|
DsRune::inscribe_on 方法 | inscribe_widget 上的 on_handlers: &[DsOn] |
DsNode::On 变体 | 折进部件 |
DsTreeToTokens trait | DsRune codegen 接口 |
ds_traverse 模块 | xrune::ds_rune::decipher::decipher |
crate 名 xwrapup / xrune_derive / xrune_parser / xrune_macros | xrune-sigil / xrune-nexus / xrune-incant / xrune |
源码出处
- 完整版本流变 changelog:
CHANGELOG.md - 现行的 trait 接口面:
crates/xrune_nexus/src/ds_rune/mod.rs - 现行的 AST 接口面:见 符文图谱
同道比较
xrune 立身于一片 Rust DSL / UI 宏林立的同道之间。要快速摸清它在哪一档:
| xrune | typed-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(
leptos、dioxus、yew、或者直接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 必填;其余键由符文师定义(world、theme…)。 |
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() } | 事件咒符・形态 B | on 在 body 之后,附着到前一个兄弟部件。 |
Widget () on Tap { … } { … } | 事件咒符・形态 C | on 在属性与 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 |
xrune | — | pub mod default_rune; · pub use xrune_incant::ui; · pub use xrune_nexus::*; |
xrune-fmt | 誊章 | xrune-fmt <file.rs> [--check](二进制) |