Universal Ctags readtags 排序器 DSL 实战:用 `cmp` 与 `or` 组合表达式控制标签排序
Universal Ctags readtags 排序器 DSL 实战:用 cmp 与 or 组合表达式控制标签排序
本文围绕 Universal Ctags 仓库中的 Tmain 测试用例 readtags-sorter-cmp_or-sform.d 展开,系统讲解 readtags 的 -S / --sorter 排序表达式(sorter expression)的写法:如何使用 <>(比较)、<or>(短路选择)等算子把多个比较条件组合成一条"cmp-or"风格的排序规则,并借助真实输入文件、预期输出与 readtags 手册 中的语义说明,掌握让"函数定义排在原型之前、同时保持按名称排序"这类业务排序需求的实现方法。
一、这个测试用例在验证什么
readtags-sorter-cmp_or-sform.d 是 Universal Ctags 仓库中一个 Tmain 级集成测试目录,它验证的是 readtags 排序器 DSL(sorter DSL)中"比较 + 或"组合表达式的行为。目录结构如下:
Tmain/readtags-sorter-cmp_or-sform.d/
├── README.md # 测试说明:如何生成 output.tags
├── run.sh # 测试脚本:执行 readtags -S 排序表达式
├── input.d/
│ ├── bar.c # 含 bar 的定义与原型
│ ├── decl.h # 含 foo 的原型
│ └── foo.c # 含 foo 的定义
├── output.tags # 排序前的标签文件(由 ctags 生成)
├── stdout-expected.txt # 排序后的预期输出
├── stderr-expected.txt # 预期标准错误(为空)
└── exit-expected.txt # 预期退出码(0)
测试脚本 run.sh 的核心是一行排序表达式:
${READTAGS} -et output.tags -S '
(<or> (<> $name &name)
(and (eq? $kind "p") (eq? &kind "f") 1)
(and (eq? $kind "f") (eq? &kind "p") -1))' -l
其中 -S 即 --sorter,后面跟随的 Lisp 风格表达式就是排序器 DSL;-l 表示以列表形式输出所有标签,-e 表示在输出字段中带上字段名(如 kind:f)。
该用例的输入取自上游 issue #4015 的讨论(见 README.md),核心问题是:如何让同名的 C 函数"定义(kind f)"排在"原型(kind p)"之前,同时整体仍按标签名排序。
二、先用 ctags 生成待排序的标签文件
README.md 给出了生成 output.tags 的命令:
ctags --kinds-C=+p -o output.tags input.d/foo.c input.d/bar.c input.d/decl.h
--kinds-C=+p:显式启用 C 语言的prototype(原型,kind 字母p)这一标签种类。ctags 默认不会为函数原型生成标签,必须显式打开;-o output.tags:把标签输出到指定文件;- 三个输入文件内容分别为:
/* input.d/foo.c */
void foo(void) {}
/* input.d/bar.c */
void bar(void);
void bar(void) {}
/* input.d/decl.h */
void foo(void);
生成的 output.tags 中,四个条目按 ctags 自身的排序逻辑排列(先按名称,再按输入文件路径等):
bar input.d/bar.c /^void bar(void) {}$/;" f typeref:typename:void
bar input.d/bar.c /^void bar(void);$/;" p typeref:typename:void file:
foo input.d/decl.h /^void foo(void);$/;" p typeref:typename:void
foo input.d/foo.c /^void foo(void) {}$/;" f typeref:typename:void
注意这里 bar 的两条标签中,定义(f)排在了原型(p)前面,而 foo 的两条标签恰好相反:原型(p,来自 decl.h)排在定义(f,来自 foo.c)前面。这正是该测试要解决的排序不一致问题——我们希望得到统一的"定义在前、原型在后"的顺序。
三、readtags 排序表达式基础:$、& 与返回值语义
在深入组合表达式之前,先明确排序器 DSL 的基本语义(详见 readtags 手册 "Sorting" 一节):
- 排序时,readtags 对两条标签条目求值排序表达式,根据结果决定先后,直到所有条目排定;
- 表达式中
$访问的是"当前条目"(文档中称为 -entry)的字段,`&` 访问的是"另一条被比较的条目"(&-entry)的字段,例如 `name、&kind、$line、&input`; - 表达式结果必须是 -1、0 或 1:-1 表示 $-entry 应排在 &-entry 之前;1 表示相反;0 表示两者顺序不确定;
- 核心比较算子
<>用于比较两个字符串或两个数字(数字用于line:、end:等数值字段):(<> a b)中a < b得 -1,a > b得 1,相等得 0;字符串比较使用strcmp(3)的语义。
最基本的按名称排序表达式就是:
(<> $name &name)
含义:"如果 $-entry 的标签名更小,就把它排在 &-entry 前面"。
四、<or>:把多个比较条件串成一条 cmp-or 规则
单靠 <> 只能表达一种比较维度。当需要"先按 A 排,A 相同时再按 B 排"这类多级排序时,就要用到 <or> 算子(sorter 引擎中的 "or" 特殊形式,见 dsl/sorter.c 的绑定表)。
<or> 会从左到右依次对子表达式求值,直到某个子表达式返回 -1 或 1(即比较已经分出胜负),此时立即以该值作为整个排序表达式的返回值;如果所有子表达式都返回 0(无法分出先后),整体结果就是 0。
手册中的标准例子是"先按输入文件名排序,同一文件内再按行号排序":
(<or> (<> $input &input)
(<> $line &line))
这就是 cmp-or 模式:多个"比较(cmp)"通过"或(or)"串联,形成优先级链。本测试用例正是这一模式的实战变体——它在 <> $name &name 之外,又追加了两个基于 kind 的判断分支。
五、逐行拆解测试用例的排序表达式
回到 run.sh 中的表达式:
(<or> (<> $name &name)
(and (eq? $kind "p") (eq? &kind "f") 1)
(and (eq? $kind "f") (eq? &kind "p") -1))
逐行含义如下:
(<> $name &name):第一优先级——按标签名排序。绝大多数情况下,两个条目的名字不同,比较立刻分出胜负,后续分支不会执行;(and (eq? $kind "p") (eq? &kind "f") 1):当名字相同时执行。若 $-entry 是原型(kind为"p")而 &-entry 是定义(kind为"f"),整个and求值为1,即把 $-entry 排在 &-entry 之后(原型在定义之下);(and (eq? $kind "f") (eq? &kind "p") -1):对称的分支。若 $-entry 是定义而 &-entry 是原型,求值为-1,把 $-entry 排在 &-entry 之前(定义在原型之上)。
注意第 2、3 分支只处理"同名且 kind 分别为 p/f"的情况;如果同名但 kind 相同(例如两个定义),两个 and 分支都返回 #f(假),<or> 最终得到 0,顺序保持不确定——这符合预期,因为同名同 kind 的标签顺序本来就没有业务要求。
and 的求值规则与 or 相反:全部子表达式为真才返回最后一个子表达式的值,任一为假则整体为假(详见 dsl/dsl.c 中 sform_if / sform_cond 附近的特殊形式实现)。这里 1 和 -1 就是 and 的"真值结果",直接充当 <or> 需要的 -1/1 判定值。
与"if 技巧"的等价关系
readtags 手册 还介绍了一种等价的通用写法:在 sorter 中复用 filter 表达式,用 if 把布尔判断转成可比较的整数,例如"把 file kind 的标签沉到最下面":
(<> (if (eq? $kind "file") 1 -1)
(if (eq? &kind "file") 1 -1))
其原理是:$-entry 满足条件而 &-entry 不满足时,等价于 (<> 1 -1),结果为 1,把 $-entry 排下去。测试用例中的 (and ... 1) / (and ... -1) 分支写法本质上与此同构,但更直接——它不比较两个转换后的数,而是分别针对两种方向给出显式的 -1/1 常量,语义更一目了然。
六、预期输出验证:定义真的排到了原型前面
把 -S 排序表达式应用到 output.tags 后,测试预期的标准输出 stdout-expected.txt 为:
bar input.d/bar.c /^void bar(void) {}$/;" kind:f typeref:typename:void
bar input.d/bar.c /^void bar(void);$/;" kind:p file: typeref:typename:void
foo input.d/foo.c /^void foo(void) {}$/;" kind:f typeref:typename:void
foo input.d/decl.h /^void foo(void);$/;" kind:p typeref:typename:void
对比排序前的 output.tags 可以清晰看到效果:
bar的两条标签:定义(f)仍然在前,原型(p)在后;foo的两条标签:顺序被翻转——定义(f,来自 foo.c)被提升到原型(p,来自 decl.h)之前;- 整体仍然按标签名字典序排列(bar 组在前,foo 组在后),
<> $name &name作为第一优先级分支生效。
同时 stderr-expected.txt 为空、exit-expected.txt 为 0,表明表达式求值过程无报错、正常退出。测试框架会比对这三个文件,任何一行输出或退出码偏差都会导致测试失败,这保证了排序 DSL 行为的稳定性。
七、从源码看 cmp-or 的实现
排序表达式并非魔法,它由 readtags 内嵌的 DSL 解释器执行。相关实现位于 dsl/sorter.c:
- sorter 引擎的特殊形式表中注册了
<or>,绑定到sorter_sform_cmp_or(见 dsl/sorter.c 与 dsl/sorter.c); - 该函数正是实现"短路求值"的关键:对参数逐一求值,遇到 -1/1 立即返回;
<>、eq?、and、if等算子则来自通用 DSL 层(dsl/dsl.c中的sform_or、sform_if、sform_cond等),sorter、filter、formatter 三个引擎共享同一套求值核心,只是注册的算子集合不同(DSL_SORTER引擎标识见 dsl/dsl.h);- 表达式在运行时先经
dsl_compile (DSL_SORTER, exp)编译为内部表示再求值(见 dsl/sorter.c),因此长表达式也不会逐条解释文本。
如果读者想了解 sorter 表达式的完整算子清单,可以运行 readtags -H sorter(help 帮助),手册也建议用 readtags -H filter 查看 filter 中可用的函数与变量——filter 算子集是 sorter 的子集,且可以直接嵌入 sorter 表达式使用。
八、实战要点与扩展思路
- 优先级即顺序:在
<or>中,越靠前的分支优先级越高。想调整排序主次,直接调整分支顺序即可; - 同名冲突处理:当主排序键(如
name)相同但仍有其他排序需求(如按 kind、按行号、按输入文件)时,cmp-or 是标准解法;可参考手册中的(<> $input &input)/(<> $line &line)组合; - 翻转方向:
(*- (<> a b))等价于(<> b a),可用来在不改写分支的情况下反向排序(手册 "Sorting" 一节有说明); - 复用 filter 技巧:复杂的业务条件(如"只把 file 作用域的标签沉底")可以用
(<> (if 条件 1 -1) (if 条件 1 -1))模板嵌入,本测试的and分支则是面向固定 kind 对的更紧凑写法; - 自行复现:仓库是只读的,但可以本地检出后在
Tmain/readtags-sorter-cmp_or-sform.d/目录下运行./run.sh(或经 Tmain 测试框架执行),通过修改-S表达式观察排序结果的变化,是学习排序 DSL 最直接的实验方式。
参考资料(仓库内)
- 测试说明与标签生成命令:Tmain/readtags-sorter-cmp_or-sform.d/README.md
- 排序表达式与执行脚本:Tmain/readtags-sorter-cmp_or-sform.d/run.sh
- 排序前后对比:(output.tags) 与 (stdout-expected.txt)
- 排序器 DSL 语义详解:docs/man/readtags.1.rst
- 排序器引擎实现:dsl/sorter.c、dsl/dsl.c、dsl/dsl.h