Universal Ctags readtags 排序器 DSL 实战:用 `cmp` 与 `or` 组合表达式控制标签排序

原创2026-10-03 23:52:331,863 阅读
文章标签:开发工具CLI

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

逐行含义如下:

  1. (<> $name &name):第一优先级——按标签名排序。绝大多数情况下,两个条目的名字不同,比较立刻分出胜负,后续分支不会执行;
  2. (and (eq? $kind "p") (eq? &kind "f") 1):当名字相同时执行。若 $-entry 是原型(kind 为 "p")而 &-entry 是定义(kind 为 "f"),整个 and 求值为 1,即把 $-entry 排在 &-entry 之后(原型在定义之下);
  3. (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 表达式使用。

八、实战要点与扩展思路

  1. 优先级即顺序:在 <or> 中,越靠前的分支优先级越高。想调整排序主次,直接调整分支顺序即可;
  2. 同名冲突处理:当主排序键(如 name)相同但仍有其他排序需求(如按 kind、按行号、按输入文件)时,cmp-or 是标准解法;可参考手册中的 (<> $input &input) / (<> $line &line) 组合;
  3. 翻转方向:(*- (<> a b)) 等价于 (<> b a),可用来在不改写分支的情况下反向排序(手册 "Sorting" 一节有说明);
  4. 复用 filter 技巧:复杂的业务条件(如"只把 file 作用域的标签沉底")可以用 (<> (if 条件 1 -1) (if 条件 1 -1)) 模板嵌入,本测试的 and 分支则是面向固定 kind 对的更紧凑写法;
  5. 自行复现:仓库是只读的,但可以本地检出后在 Tmain/readtags-sorter-cmp_or-sform.d/ 目录下运行 ./run.sh(或经 Tmain 测试框架执行),通过修改 -S 表达式观察排序结果的变化,是学习排序 DSL 最直接的实验方式。

参考资料(仓库内)

登录后查看全文
ctags