Tinymist 的 Typst 无用声明检测
前言
给 Tinymist 贡献也有一段时间了,这阵子看着它静态分析的部分做得还是比较有限,于是有了给它补一些静态分析功能的想法,死代码检测是个开始。
对于死代码检测,在软件分析中有学到,如可达性分析、常量传播分析、到达定义分析和活跃变量分析。这些方法解决的问题是:某段代码是否永远不可能被执行,某个赋值是否永远不会影响后续计算,或者在程序的特定位置变量是否会被使用。没有使用的话,就可以对寄存器分配做调整,或者省略无用的赋值。
这些分析在现代 IDE / LSP 中已经非常常见。然而,从 Tinymist 当前的实现出发,引入 CFG 与执行路径分析的成本较高(虽然正在实现相关的 PR draft),也不太符合 Typst 这种“重排版、轻逻辑”的语言特性。在 Tinymist 目前的能力范围内,我们能够可靠获取的信息主要是各类符号的声明位置和各类表达式中对符号的语义引用关系,一个自然的想法是:如果一个声明在当前语义结构中从未被引用过,那么它很可能是无用的。
也就是说我们可以实现一个基于语义引用的无用声明检测。
基于语义引用的无用声明检测
一个简单的例子
考虑下面这段 Typst 代码:
#let title = "My Document"#let subtitle = "Draft"
#let format-title(t) = upper(t)
#let unused-helper(x) = x + 1
#format-title(title)其中各个声明及其使用情况如下:
-
title:在#format-title(title)中作为实参被读取,因此是 used。 -
subtitle:在文件中没有任何引用,因此是 unused。 -
format-title与其参数t:format-title被调用,因此是 used;- 参数
t在函数体upper(t)中被读取,因此是 used parameter。
-
unused-helper与其参数x:unused-helper从未被调用,因此函数声明本身是 unused;- 但参数
x在函数体中被读取,因此并不能算作“未使用参数”。
在这种基于引用关系的检测中,subtitle 与 unused-helper 会被报告为未使用的声明。
集合模型:声明与引用
如果我们将问题进一步抽象,就会发现这本质上是一个集合运算问题。在单个语义单元(例如一个文件)内,我们可以定义:
Decls:该文件中所有声明的集合Refs:该文件中所有被引用到的声明集合
那么,未使用的声明集合就是:
Unused = Decls − Refs伪代码描述
这一思路可以用如下三个步骤来实现:
-
收集所有声明
function collect_all_declarations(ast):Decls = empty setfunction visit(node, scope):if node introduces a new declaration:d = extract_declaration(node, scope)Decls.add(d)for each child in node.children:next_scope = update_scope(node, scope)visit(child, next_scope)visit(ast.root, GLOBAL)return Decls -
收集所有引用
function collect_all_references(ast, resolution):Refs = empty setVisited = empty setfunction visit_expr(expr):if expr in Visited:returnVisited.add(expr)if expr resolves to declaration d:Refs.add(d)for each sub in expr.subexpressions:visit_expr(sub)for each top-level expr in ast:visit_expr(expr)return Refs -
计算未使用集合
Unused = {}for each d in Decls:if d not in Refs:Unused.add(d)
在单文件场景下,这个模型简单、直观,而且非常有效。
分析边界与不敏感性
在单文件这一前提下,这种分析具有如下特性:
- 流不敏感(flow-insensitive):不关心语句的执行顺序;
- 路径不敏感(path-insensitive):不区分不同的执行路径。
有趣的是,这种“不敏感性”在这里反而是优势。因为我们关注的是一个存在性问题:只要存在一次语义引用,这个声明就不应该被视为 unused。即使是在 if false { ... } 这样的分支中出现的引用,我们通常也不希望因此将声明标记为未使用,而更倾向于像 C/C++ 中的宏代码那样进行弱化处理。然而,这一切都建立在分析单元封闭的前提之上。一旦声明与引用跨越文件边界,这个简单的集合模型就开始显得力不从心。
当分析跨越文件边界
考虑如下两个文件:
-
main.typ#import "util.typ": foo#foo() -
util.typ#let foo() = 1#let bar() = 2
如果我们分别、独立地对两个文件进行无用声明检测:
-
在
main.typ中:Decls = { foo }Refs = { foo }Unused = ∅
-
在
util.typ中:Decls = { foo, bar }Refs = ∅Unused = { foo, bar }
foo 明明被 main.typ 使用了,却在 util.typ 中被误判为死代码。这是因为单文件分析无法“看到”外部对该文件的引用。
Tinymist 的解决思路
为了解决这个问题,Tinymist 在分析库文件(如 util.typ)时,会:
- 反向查找所有依赖该文件的其他文件;
- 检查这些文件是否引用了该模块导出的符号;
- 将这些跨文件引用纳入使用判定。
这样,作为库存在的 util.typ 中,被外部使用的 foo 就不会被误判为 unused。然而,随着我们继续深入,就会发现:跨文件引用并不是问题的全部。
从“跨文件”到“间接使用”
进一步观察 import 相关的各种写法,可以发现一个更本质的问题:有些声明并不是被“直接引用”的,而是通过中间声明间接使用的。这在 Typst 的模块系统中尤为常见。
Typst 的几种模块导入形式
Typst 中最常见的导入方式包括:
import "mod.typ",随后使用mod.foo()import "mod.typ" as mimport "mod.typ": fooimport "mod.typ": *
这些形式在“声明如何被使用”这一问题上的语义并不完全一致。
使用传播的例子
考虑下面的代码:
#import "util.typ": foo as bar#bar()这里真正被调用的是 bar(),其声明点在 foo as bar 处。
如果仍然简单地用 Decls − Refs 来判断:
bar会被标记为 usedfoo会被误判为 unused
但直觉上我们很清楚,bar 的使用隐含了对 foo 的使用。换句话说,这里存在一条明确的依赖关系:
bar → foo从集合到图
一旦我们承认这种“使用会沿声明关系传播”,死代码检测的问题就不再是简单的集合相减,而是在声明依赖图上做一次可达性分析。
在这个模型中:
- 每个声明是一个节点;
- “使用会传播”的关系是一条有向边;
- 所有直接被引用的声明,无论是否跨文件,构成初始集合
Used₀; - 从
Used₀出发,沿依赖边做闭包计算; - 所有可达的声明,均应被标记为 used。
需要注意的是,这里的“可达性”并不是控制流意义上的可达性,而是纯语义层面的声明依赖可达性,完全不涉及执行顺序,因此不需要构建 CFG。
在模块相关场景中,初始使用集合通常包括:
import "mod.typ"后使用dict(mod)时的modimport "mod.typ"后使用mod.foo()时的fooimport "mod.typ": foo后直接调用foo()时的fooimport "mod.typ": foo as bar后直接调用bar()时的bar- 以及当前文件中所有直接引用的本地声明
通配符导入的特殊性
import "mod.typ": * 是一个特殊情况。其实现中并不存在从模块到其子声明、或从子声明回到模块的传播边。因此,这类导入的 used 与否并不是通过依赖传播得出的,而是作为一个模块级判定,只要该模块文件中存在任意已使用的声明,该通配符导入就被视为 used。

总结与体验
从单文件内的集合差分,到跨文件、跨别名的声明依赖图可达性分析,我们最终在 Tinymist 中实现了一套语义驱动的无用声明检测机制。在此基础上,我也同步实现了配套的 code actions。对确认无用的变量或 import,支持一键删除。对需要暂时保留的变量(如接口参数),支持自动添加 _ 前缀以消除警告。
虽然目前尚未引入基于控制流的深度分析,但对于 Typst 这种以文档为核心的语言来说,基于声明依赖图的分析已经覆盖了绝大多数日常使用场景。下一步计划可能是通过 CFG 实现不可达代码的检测。道阻且长!