typst 这个项目我断断续续看了有段时间,最近趁着一个周末,把它的参数解析核心模块 args.rs 从头到尾过了一遍。typst 是用 Rust 写的现代排版系统,目标就是替代 LaTeX 那种老古董的写作体验,它有一套自己的标记语言,编译速度比 LaTeX 快得多。而 args.rs 这个文件,就是负责把用户在 typst 文档里写出来的函数调用参数,转换成内置函数实现里真正能用的 Rust 类型。说白了,它是 typst 脚本系统与 Rust 实现层之间的“翻译官”。
如果你准备读 typst 源码,或者自己正在写一个需要对外暴露脚本 API 的解释器、渲染引擎、构建工具,那这个文件非常值得精读。它解决的问题很具体:面对一堆动态类型、既有位置参数又有命名参数的函数调用,怎么做到类型安全、错误信息友好、同时让每个内置函数实现写起来几乎零成本。接下来我按自己的理解,从设计思路一路拆到具体实现,最后把我实际踩过的编译坑和排查方法也一并交代清楚。
1. 先搞清楚 args.rs 在整个 typst 里的位置
1.1 typst 参数解析到底要解决什么问题
typst 的语法树经过求值器(evaluator)处理之后,会得到一系列可以执行的表达式,其中就包括函数调用。比如你在文档里写 #text(size: 12pt, "Hello"),这里 text 是函数名,size: 12pt 是命名参数,"Hello" 是位置参数。typst 的内置函数是用 Rust 写的,每个函数在 Rust 侧都有一个对应的实体。问题是:typst 的脚本语言是动态类型的,Value 枚举可以装下整数、浮点数、字符串、数组、字典、函数、颜色、长度、内容块等各种类型;但 Rust 是静态强类型,你不能直接把一个 Value 塞给 fn text(..) 去用,必须做一层转换和校验。
这一层转换和校验,就是 args.rs 的核心职责。
更深一层说,typst 的调用语法并不是简单的一对一映射。同一个函数,用户可能用位置参数调用,也可能用命名参数调用,还可能混着用;有些参数有默认值,用户不传也能跑;有些参数允许被设计成可重复收集;有些参数之间存在隐式类型转换,比如传一个整数也能被当作浮点数接收。如果这些规则全部散落在每个函数实现里,代码会迅速腐烂成一大堆 match 加 unwrap_or_default,而且错误提示会五花八门。args.rs 的使命就是把“怎么从调用参数里取一个类型安全的 Rust 值”统一收敛起来,让函数作者只需要声明自己要什么,剩下的交给框架。
1.2 为什么不直接在每个函数里手写解析
我最开始对 args.rs 的存在是持怀疑态度的。Rust 的 match 表达式那么强大,直接在函数实现里写几行匹配不就好了?但看完这个文件之后,我意识到手写解析有三个很难容忍的后果。
第一是重复验证逻辑。typst 内置函数有一两百个,每个函数少则两三个参数,多则十几个。手写解析意味着每个函数都要处理“参数少了怎么办”“参数类型不对怎么办”“这个参数既允许位置传也允许命名传怎么办”,这些逻辑在不同函数之间会大量重复。第二个问题是错误信息不一致。一个函数报 expected number but found string,另一个函数报 invalid argument,用户面对这种提示会非常崩溃。args.rs 把所有错误统一成一套诊断体系,带源码位置、带预期类型、带实际类型,体验完全不一样。第三是后续维护成本。一旦你决定在某个 Value 类型上增加一种类型转换规则,手写解析模式需要把所有相关函数翻出来逐个改,而通过统一的 trait 体系,只要在类型实现里改一处,所有函数立刻生效。
所以 args.rs 的设计方向从一开始就是清晰的:把“动态参数到静态类型的转换”抽象成一个可组合、可扩展的机制,让类型自己知道如何从 Args 里把自己解析出来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三个核心数据结构:Args、Named、Spanned
2.1 Args:位置参数和命名参数的分流容器
args.rs 里最先看到的是一等公民 Args。它是一个生命周期参数化的结构体,简化下来大致是这样的:
rust复制pub struct Args<'a> {
pos: Vec<Spanned<Value>>,
named: HashMap<&'a str, Spanned<Value>>,
}
pos 保存位置参数,named 保存命名参数。这里有几个设计意图值得展开说。
位置参数用 Vec 而不是 VecDeque 是有道理的。解析过程是按顺序消费的,每次从头取一个、再留下剩余部分。虽然从头部 pop 是 Vec 的弱项,但 Args 可以记录当前消费的游标,或者用 Vec::remove(0) 直接弹出。实际上 typst 这个文件里用的是从头部弹出、返回剩余切片的方式,配合迭代器语义,性能足够好。因为一个函数调用的位置参数数量一般是个位数,remove(0) 的 O(n) 在这里完全可以忽略。
命名参数用 HashMap<&str, Spanned<Value>> 而不是直接 HashMap<String, ...>,这一点很关键。这意味着哈希表的 key 是借用字符串,而不是自己持有所有权。函数签名里参数名是编译期确定的字符串字面量,比如 "size"、"body"、"stroke",它们拥有 'static 生命周期;而调用方的参数名则来自 typst 脚本语言里的字符串,生命周期与脚本执行上下文绑定。Args 在创建时把命名参数的名字统一收集成 &str,生命周期归到 'a,后续查询就完全不必纠结字符串所有权转移的问题。
还有一个容易被忽略的细节:named 的类型不是 HashMap<&str, Value>,而是 HashMap<&str, Spanned<Value>>。每个参数值都带着自己的源码位置信息,这个设计几乎贯彻了 typst 全项目。
2.2 Named:同一个参数,两种来源
typst 的函数调用允许一个参数以两种方式提供:写 #image("a.png"),也允许写 #image(path: "a.png")。在参数解析层面,这两种来源需要被统一成同一个抽象,那就是 Named 枚举:
rust复制pub enum Named<T> {
Positional(T),
Named(T),
}
这个枚举本身逻辑不复杂,但它回答了一个核心问题:当函数作者写 args.expect::<Content>("body") 时,他是在表达“我需要一个叫 body 的参数,它来自位置参数也行,来自命名参数也行”。如果没有这层抽象,你就必须在每个函数里写两个分支去处理 pos 和 named,然后还要自己定义“如果位置参数先出现了,是否还允许命名参数覆盖它”这类优先级规则。
Named 还跟类型转换体系有很深的配合。一个实现了解析逻辑的类型,面对 Named::Positional(v) 和 Named::Named(v) 时,内部的 Value 类型是相同的,区别只在于“这个参数叫什么名字”,而这个名字在错误信息里至关重要。用户把 size 写成了 siz,或者把位置参数放在错误的位置上,诊断里必须告诉他“这个位置的第 2 个参数应该是什么”,而不是笼统地丢一句“参数类型不对”。
2.3 Spanned:让错误定位不迷路
排版系统的核心体验之一就是精准报错。typst 对源码位置(span)的处理非常细致,几乎每个 AST 节点和运行时值都携带 span。Spanned<T> 就是包装这个信息的通用结构:
rust复制pub struct Spanned<T> {
v: T,
span: Span,
}
在 args.rs 中,Spanned<Value> 是最常见的内层载体。参数解析失败时,不是简单地返回一个 Err("expected number"),而是构造一个带 span 的完整诊断对象。这样错误报告就能在源码文件里精确高亮出错的参数位置,如果你用的是 VS Code 插件甚至 neo-vim 的 typst 集成,点击错误还能直接跳到对应代码位置。
更重要的是,span 不只是给报错用的。typst 的 Content 块、链接、引用等元素在渲染成最终文档时,也需要携带来源信息,这样最终生成的 PDF 才能支持反向定位,点一下 PDF 里的元素能跳回源代码。参数解析过程中拿到的 Value 如果本身内部包含内容块,这些内容块的 span 会被保留到后续处理。所以在 Spanned 上多花一点结构成本,换来的是整个工具链的定位能力。
3. Accept trait:让类型自己决定怎么被解析
3.1 Accept 的完整思路
如果 Args 只是仓库,那 Accept trait 就是仓库里的自动分拣机器人。它的核心思想是:任何想要从 Args 中被解析出来的类型,都需要实现 Accept。大致是这样一个形态:
rust复制pub trait Accept<'a>: Sized {
fn accept(&mut self, vm: &mut Vm, args: &mut Args<'a>) -> Result<Self>;
}
这里的 vm 是求值器上下文,args 是参数容器。一个类型实现 Accept 之后,就可以用 args.expect::<T>("参数名") 来把它取出来。
说实话,我第一次看到这个 trait 的时候有点懵:为什么 accept 接受 &mut self 而不是 self?这其实是个很妙的点。因为有些类型的解析是“有状态”的,比如当你解析一个 Vec<T> 时,你需要持续消费多个位置参数,直到参数耗尽或者类型不匹配为止。这需要一个内部游标记录已经收集了多少个元素。还有 Option<T> 的情况,它尝试解析一个 T,如果遇到类型不匹配或参数不够,不是立刻报错,而是优雅地返回 None,这需要临时吞掉一个参数然后再“吐”回来——如果 &mut self 上保存了临时状态,实现起来会自然很多。
Accept 的存在让“参数解析策略”变成了一套开放协议。你完全可以在自己的项目里照这个模式扩展出新的解析器:比如自定义一个 Range 类型要求参数必须是 "1pt" 这种带单位的字符串;或者定义一个 Alignment 枚举,把 "left"、"right"、"center" 映射到枚举值。你甚至可以在不修改 Value 枚举的前提下,为同一个底层类型实现多种解释方式,只要通过不同的 newtype 包装即可。
3.2 基础类型的 Accept 实现细节
args.rs 里为一系列基础类型实现了 Accept,我把它们的行为差异整理了一下:
| 目标类型 | 接受的值类型 | 特殊行为 |
|---|---|---|
i64 |
Value::Int |
如果值是 Value::Float 且刚好是整数,可能窄化转换 |
f64 |
Value::Int, Value::Float |
整数自动转浮点,这是最常用的隐式转换 |
bool |
Value::Bool |
无隐式转换,避免把 1 或 0 变 bool 的坑 |
String/&str |
Value::Str |
字符串是 typst 里的核心类型,经常被解引用成 &str |
Content |
Value::Content |
内容块,布局系统的核心 |
Length |
Value::Length |
长度类型,支持带单位的尺寸 |
Option<T> |
任意 | 解析失败时返回 None,不报错 |
Vec<T> |
任意 | 持续消费剩余位置参数直到失败,返回列表 |
以 f64 的实现为例,它允许传入整数,也允许传入浮点。这个设计的合理性在于:用户写 #line(length: 20) 和 #line(length: 20.5) 在语义上不应该有区别,长度就是一个数值概念。但如果反过来,i64 也接受浮点,就会出现 20.7 被静默截断成 20 的诡异行为,这是非常反直觉的。所以 i64 只接受整数值的浮点窄化,不接受任何小数部分。
String 类型的实现里还有一个细节:它不只是匹配 Value::Str,还会在解析后把底层字符串引用保存下来。这个字符串的生命周期由 vm 和 Value 共同保证,不能随意 drop。Rust 的类型系统在这个环节帮了大忙,编译器在静态层面就拦截了“字符串被释放后仍持有引用”的非法情况。
3.3 Option 与 Vec:两种特殊场景的处理
Option<T> 的 Accept 实现是 args.rs 里最有技巧性的部分之一。它要解决的问题是:当期望一个 Option<Length> 时,如果调用方根本没有传这个参数,应该返回 Ok(None);如果传了但类型不对,应该报错;如果传的类型对,就返回 Ok(Some(value))。实现思路大概是这样:先看参数位置上有没有值,没有就返回 None;有就尝试解析 T,如果 T 解析失败且错误类型是“类型不匹配”,就返回 None;其他错误返回 Err。这样,可选参数与必选参数的区分就被彻底封装进了类型系统,函数作者只需要声明函数签名里的参数是 Option<Length> 还是 Length,剩下的逻辑全部由 Accept 接管。
Vec<T> 的实现则要走另一条路。它需要把剩余的所有位置参数都尽可能解析成 T,直到遇到一个无法转换的元素为止。典型的应用场景是:一个函数接受可变数量的内容块作为正文,比如 #box(rect, circle),它会把 rect 和 circle 都解析成 Content 塞进一个 Vec。这里最明显的坑是沉默吞错:Vec<T> 收集到类型不匹配时会停止收集,但它怎么知道是自己应该停下,还是真正的函数参数顺序错误?答案是通过位置信息判断,如果停止的位置之后还有额外的命名参数需要匹配,则不报错;如果停止的位置正好是其他位置参数的起始点,也是合法的。这套逻辑用文字讲容易绕,但代码实现里就是不断尝试 T::accept,一旦遇到 Err 就重置内部状态并结束收集,非常优雅。
4. 实际调用链与关键代码走读
4.1 一个典型的函数定义长什么样
在 typst 源码里,一个内置函数通常通过宏来描述签名,但落到 args.rs 这层,实际就是 Args 的各个方法依次调用。我梳理下来,一个典型函数的核心逻辑长得像这样:
rust复制fn text(
vm: &mut Vm,
args: &mut Args,
) -> Result<Value> {
let body = args.expect::<Content>("body")?;
let size = args.named::<Option<Length>>("size")?
.unwrap_or(Length::pt(11.0));
let weight = args.named::<Option<Weight>>("weight")?;
let fill = args.named::<Option<Color>>("fill")?
.unwrap_or(Color::BLACK);
Ok(Value::Content(text_impl(vm, body, size, weight, fill)?))
}
这个模式非常典型:先取位置参数,再逐个取命名参数,并处理默认值。expect 方法对应必选参数,如果调用方没提供或类型错误,它会抛出一个带 span 的诊断信息。named 方法则专门查命名参数表,返回 Option<T>,配合 unwrap_or 实现默认值逻辑。
这里有一个值得注意的函数签名设计:为什么 expect 的泛型参数是调用点指定的,而不是在函数定义里指定?比如 args.expect::<Content>("body"),泛型参数 Content 出现在调用点。这是因为 Args::expect 的完整签名就是 fn expect<'a, T: Accept<'a>>(&mut self, name: &str) -> Result<T>,它要返回 T 本身,而不是 Option<T>。调用点显式标注 Content 后,Rust 编译器就能在 Accept 的 impl 列表里找到 Content 对应的实现,并执行转换。如果你打算往 typst 提交新函数,记住这个调用模式就够了。
4.2 expect、find、named、all 这几个方法各自干什么
Args 对外暴露的方法不算多,但每个都承载了不同的语义,我总结成了一张表:
| 方法 | 作用 | 使用场景 |
|---|---|---|
expect |
消费一个必选位置参数,失败则报错 | 最常用,取正文、取主参数 |
find |
查找一个参数,可能是位置或命名,取不到返回 None |
参数来源不确定时 |
named |
只按名字查命名参数 | 可选命名参数,配合 unwrap_or 给默认值 |
all |
消费剩余全部位置参数 | 可变参数场景,收集多个内容块 |
eat |
尝试消费一个参数,但不强制成功 | 用于处理可选位置参数,避免污染 |
实际编码中,named 和 find 的区别经常把人绕晕。我一开始也踩过:named 只查 named 哈希表,不碰位置参数;find 则先看是否有命名参数,再看当前位置参数是否可以转换。大多数情况下你想要的是 named,因为你已经显式声明了参数名;只有当你确实希望“这个参数既可以用名字传,也可以按位置传”时,才需要 find。把这两个混用会导致函数对输入方式的约束比预期弱,用户可能漏掉参数名自己都没发觉。typst 自己的一些内置函数在历史版本中就对这个问题做过修正,说明这确实是实际设计中容易踩的坑。
4.3 错误信息是怎么组织出来的
args.rs 的错误处理是整篇文章里我认为最值得学习的一部分。错误信息不是简单 String,而是一个 SourceDiagnostic,它包含 span、错误级别、附加提示等结构化字段。当 expect 发现缺少参数时,它会读取对应位置的 Spanned<Value> 里的 span,生成一条类似“missing argument”的诊断。当类型不匹配时,它会把期望类型和实际类型都放进诊断文本,给用户一个明确的对照。
typst 的错误信息有一个细节让我印象深刻:它对“提供了但类型错误”和“干脆没提供”两种情况分别给出了不同的文案,前者强调“你给的值是啥”,后者强调“位置/名字缺了什么”。这对用户排错极有帮助。如果你自己实现一个脚本引擎,可以把这套思路抄走:错信息里不要只写“expected number got string”,要写“参数 size 需要长度值,但你提供了一个字符串”,并且带上源码行号。这一条小而具体的经验,能显著提升你们产品的可用性。
typst 还支持在诊断信息里附加“帮助列表”。比如当参数名写错时,它会把相近的正确参数名列出来,引导用户去改。这个功能在 args.rs 里是通过编译期查询所有已知参数名来实现的,实话说,启动成本有点高,但用户体验收益非常大。如果你做的是文本型脚本语言,强烈建议做一层相似的模糊匹配提示。
5. 我踩过的坑和排查技巧
5.1 两个最常见的编译错误
我自己在阅读和实验 Args 这套代码时,踩过两次比较典型的编译错误。第一个是 E0277:the trait bound MyType: Accept<'_> is not satisfied。这个错误通常出现在你想让一个自定义类型成为参数类型却忘写实现的时候。解决方法是给目标类型补上 Accept 的 impl,并且注意生命周期参数要写完整。第二个是 E0499:cannot borrow *args as mutable more than once at a time。这个容易出现在你写自定义 Accept 实现时,先调用了 args.expect 又调用 args.named,然后两个结果的生命周期被同时持有。解决方案是先取完所有参数,再统一处理它们,不要在参数解析过程中持有多个跨借用的结果。
这两类错误的共同根源是对 Args 的可变借用理解不够。记住一个原则:Args 是线性消费的,解析方法都要求 &mut self,所以所有参数的取值操作应该是顺序串行,不能同时持有两个解析结果再交叉使用。如果你发现你的解析函数里同时存在多个 let x = args.expect... 和 let y = args.named...,而且后续逻辑依赖 x 和 y 同时存在,那么你其实是在跟借用检查器打持久战。更干净的写法是:
rust复制let x = args.expect::<A>("a")?;
let y = args.named::<Option<B>>("b")?;
// 到这里再使用 x 和 y
这样编译器会很满意,你也不必到处加 clone。
5.2 调试参数解析的土办法
args.rs 这类底层模块,调试起来最麻烦的一点是错误上下文不够直观。我当时采用的是最原始也最有效的土办法:在 Accept 实现里临时插入 eprintln!,把当前参数的值和预期类型打出来。typst 有全套的测试框架,但如果你只是想快速验证一个自定义解析行为,直接打印是最快的。
另外,cargo expand 是一个核弹级工具。args.rs 是纯 Rust 写的,但 typst 的主库宏很多,有时候你会想确认某个函数签名最终展开成什么。cargo expand 能把宏展开后的代码全部吐出来,你就能看到真实的类型约束和调用路径。我读 args.rs 时至少跑了三次,每次都能发现新的调用关系。
如果遇到 panic,建议开 RUST_BACKTRACE=full 跑一遍最小用例,定位到具体是哪个 expect 出的问题。配合 typst 自带的 CLI,你可以写一个最小的 typst 文件只包含一个函数调用,然后反复调整参数类型,观察诊断输出。这样基本能覆盖大部分解析问题。
5.3 给想往 typst 提交代码的人的建议
最后给想参与 typst 开发的朋友一点建议。args.rs 是整个项目里最容易上手的入门口之一,因为它职责清晰、依赖少、不涉及复杂的设计决策。提交一个新函数或者给现有函数加一个可选参数,实际上只需要做三件事:第一,理解你要加的参数的 Value 类型;第二,在函数实现里调用 expect / named,并给默认值;第三,跑一遍相关测试,确认调用方式和错误信息都符合预期。
我建议你先从给现有参数增加一种合法的类型转换开始练手,比如让 f64 也接受某个自定义数值类型。这样改动范围小,能快速理解 Accept 协议。做一次之后,你对 typst 的函数系统会有远超看文档的深入理解。写代码时尽量不要破坏现有错误信息文案,因为用户社区对错误提示的变化非常敏感,一条成熟的错误文案往往凝聚了很多人对用户体验的打磨。
我个人读源码的习惯是:先跑通一条主线,再逐一读依赖它的分支。args.rs 就是一个完美的“主线入口”,从它扩散开去,你会接触到 Value、Vm、SourceDiagnostic、Span 等 typst 核心基础,最终对整个项目形成系统认识。如果你也有自己偏好的 Rust 项目正在阅读,不妨先找一个像 args.rs 这样的小模块作为切入锚点,后续的阅读速度会快很多。
