首页
/ Helix 高亮查询(highlights.scm)实战指南:Scopes 体系、优先级规则与自动化测试验证

Helix 高亮查询(highlights.scm)实战指南:Scopes 体系、优先级规则与自动化测试验证

2026-09-05 13:39:32作者:何举烈Damon

本文基于 Helix 官方手册中的高亮查询指南,完整讲解 highlights.scm 查询文件的编写方法:如何为语法树节点分配 highlight scope(@function@type@keyword 等)、如何正确使用 ; inherits 跨语言复用查询、如何理解"同跨度后者胜 / 嵌套节点最内层胜"两条优先级规则,以及如何用 cargo xtask query-checkcargo xtask highlight-check 对查询进行语法校验和基于 caret 断言的优先级回归测试。读完本篇,你能够为任意语言贡献或修改高亮查询,并掌握捕获点选择与验证的完整工作流。

什么是高亮查询:从语法树到主题色的映射链

highlights.scm 查询负责把 tree-sitter 语法树中的节点与一个 highlight scope(如 @function@type@keyword)关联起来;主题(theme)再把每个 scope 映射为具体颜色。这是每一门语言都必需的一个查询文件——没有它,编辑器就无法对该语言做任何语法着色。

贡献 Helix 语言支持时,查询文件必须放在固定位置:

runtime/queries/{language}/highlights.scm

例如 Rust 语言的高亮查询就位于 runtime/queries/rust/highlights.scm。整个映射链可以概括为:

语法树节点  --(highlights.scm 捕获 @scope)-->  捕获名  --(主题 toml 的 scope→style)-->  颜色/修饰符

主题的 scope 到样式的解析规则是"最长匹配":若一个捕获名是 function.builtin.static,而主题中同时定义了 function.builtinfunction,则使用更长的 function.builtin 键。

Scopes 体系:选择最具体的捕获

完整的 scope 清单及其用途记录在手册的主题页(book/src/themes.md 的 "Scopes" 一节),该清单与 Sublime Text 的 scope 命名体系大体一致,也参考了 TextMate scopes。核心语法高亮 scope 的组织结构如下(取自主题文档的完整列表):

  • attribute — 类属性、HTML 标签属性
  • type — 类型
    • builtin — 语言内置原始类型(intusize
    • parameter — 泛型类型参数(T
    • enum
      • variant — 枚举变体
  • constructor — 构造器、结构体/记录字面量、值位置的类型名
  • constant
    • builtin — 语言内置常量(truefalsenil 等)
      • boolean
    • character
      • escape
    • numeric — 数字
      • integer
      • float
  • string
    • regexp — 正则表达式
    • special
      • path
      • url
      • symbol — Erlang/Elixir 原子、Ruby 符号、Clojure 关键字
  • comment
    • line — 单行注释(//
      • documentation — 单行文档注释(如 Rust 的 ///
    • block — 块注释(/* */
      • documentation — 块文档注释(如 /** */
    • unused — 未使用变量与模式(如 __foo
  • variable
    • mutable — 可变变量(Rust 中的 mut
    • builtin — 语言保留变量(selfthissuper
      • mutable — 可变语言变量(如 mut self
    • parameter — 函数参数
      • mutable — 可变函数参数
    • other
      • member — 复合数据类型(结构体、联合体)的字段
        • private — 使用独特语法的私有字段(目前仅 ECMAScript 系语言)
  • label — CSS 中的 .class#id
  • punctuation
    • delimiter — 逗号、冒号
    • bracket — 括号、尖括号等
    • special — 字符串插值括号
  • keyword
    • control
      • conditionalifelse
      • repeatforwhileloop
      • importimportexport
      • return
      • exception
    • operatororin
    • directive — 预处理指令(C 的 #if
    • functionfnfunc
    • storage — 描述存储方式的关键词
      • typeclassfunctionvarlet
      • modifierstaticmutconstref 等存储修饰符
  • operator||+=>
  • function — 函数定义与调用
    • public — 公共函数定义
    • builtin — 语言内置函数
    • method — 方法定义与调用(obj.method()
      • public — 公共方法定义
      • private — 私有方法(独特语法,目前仅 ECMAScript 系)
    • macro — 宏调用(Rust 的 println!
    • special — C 的预处理器
  • tag — HTML 标签(如 <body>
    • builtin
  • namespace — 模块与命名空间(std::collections、包名)
  • special — Rust 的 derive、picker 中加粗的查询匹配项等
  • markupheading(含 marker16 各级标题)、listunnumbered/numbered/checked/unchecked)、bolditalicstrikethroughlinkurl/label/text)、quoterawinline/block
  • diff — 版本控制变更
    • plus — 新增(含 gutter 边栏指示)
    • minus — 删除(含 gutter
    • delta — 修改(moved 重命名/移动、conflict 冲突、gutter
  • embedded — 嵌入在字符串模板中的插值表达式(${…}

选择原则:匹配能准确描述该节点的最具体 scope。 官方手册给出的典型例子:

  • 一次方法调用应捕获为 @function.method,而不是笼统的 @function
  • 一次普通的字段访问(没有调用)应捕获为 @variable.other.member

主题文档中另有用于编辑器界面的 scope 体系(ui.backgroundui.cursor.*ui.statusline.*ui.menu.*ui.virtual.*diagnostic.* 等),以及 popup/帮助窗口中使用的 markup.normal.completionmarkup.heading.hover 等接口 scope,完整键值表同样见 book/src/themes.md。这些是主题侧消费的 scope,与 highlights.scm 中面向语法高亮的 scope 属同一套命名空间,编写主题时可一并参考。

跨语言复用:; inherits: 机制

一个查询文件可以在第一行通过 ; inherits: <lang> 声明复用另一门语言的查询,避免为派生语言重复编写整套捕获。Helix 仓库中 JavaScript 系语言的继承链就是典型示例:

也就是说 tsx 继承 typescript,而 typescript 又继承公共的 ecma 基础查询(带下划线的目录名 _typescript_jsx 表示中间产物层的共享查询,见 runtime/queries/ecma/README.md 说明)。

继承有一个重要约束:被继承的文件会针对每一个继承它的语法分别编译,因此文件中的每一个捕获都必须在这些语法中同样合法。例如 ecma 层的查询要同时能被 typescriptjavascripttsx 等语法解析,任何只针对单一语法的节点名都不能写进共享层。

优先级规则:两条规则决定谁赢得同一段文本

当多个捕获匹配同一段文本时,由以下两条规则决定最终生效的 scope:

  1. 同跨度:后匹配者胜。 覆盖相同字节区间的多个捕获中,查询文件里靠后出现的 pattern 获胜。因此应当把通用规则放在前面、需要覆盖它的具体规则放在后面
  2. 嵌套节点:最内层者胜。 当父节点和子节点都覆盖某段文本时,无论文件顺序如何,子节点(innermost)的捕获获胜。

规则 2 的一个常见后果:捕获你要捕获的那个叶子节点。如果把 @function 放在包裹调用的外层节点上,它会输给内部 identifier 上的基础规则 (identifier) @variable——所以应当把 @function 直接放在被调用的标识符节点本身。

从源码结构可以印证这一"最内层获胜"的实现方式:高亮器以作用域栈的形式工作,捕获进入/离开节点时向栈上压入/弹出 scope,取栈顶即当前字节的获胜捕获。helix-core/src/syntax.rsadvance() 返回 HighlightEvent::Push/Refresh 事件,而测试工具中同样按 active 栈的 last()(栈顶)读取获胜捕获(见 xtask/src/main.rs)。

语法无法区分时的启发式:大小写匹配

当语法本身无法区分某个 scope 时(例如 C 中全大写标识符既可能是宏也可能是常量),常用大小写启发式配合 #match? 谓词过滤:

((identifier) @constant
 (#match? @constant "^[A-Z][A-Z_]*$"))

该谓词只保留匹配正则 ^[A-Z][A-Z_]*$(全大写+下划线开头)的标识符。#match? 谓词在仓库的查询集中被广泛使用,例如 runtime/queries/bash/highlights.scm 即依赖此类谓词区分变量与常量。

测试与验证:query-check 与 highlight-check

对高亮查询的验证分两层,分别对应两类错误:

1. cargo xtask query-check [language]:语法层校验

确认查询对相应语法是合法的(节点名存在、捕获名合规等)。省略 language 参数时检查全部语言。这一层抓不到优先级错误——查询完全合法但捕获选错的写法它无法发现。

2. cargo xtask highlight-check [language]:真实高亮器回归测试

该任务运行真正的高亮器,对 tests/query/highlights/<language-id>/<name>.<ext> 下的语料文件做断言。语料采用 nvim-treesitter 风格的 caret 注释行:在代码行下方写注释,^ 字符的列位置对准上一行的 token,后跟期望的获胜捕获:

    foo(bar)
    // ^ @function
    //     ^^^ @variable
  • 每个 ^ 断言其上方列位置处获胜捕获必须与 @capture 完全一致;
  • 期望名前的 ! 表示取反(断言该列不是某个捕获);
  • 断言行必须是注释且首个 ^ 之前只有注释引导符(不含字母数字),以避免把代码里的 ^ 运算符(如 a ^ b)误判为断言行。

仓库中已有大量此类语料,例如 tests/query/highlights/rust/calls.rs

fn main() {
    invokeit();
//  ^ @function
    let s = String::new();
//          ^ @type
}

该文件断言:函数调用 invokeit 处获胜捕获是 @function(而非基础的 @variable),String::new 中的类型位置是 @type——恰好就是前文两条优先级规则的直接回归用例。目前语料覆盖 rust、cpp、go、python、typescript、tsx、javascript、bash 等数十种语言,全部位于 tests/query/highlights/ 目录。

3. cargo xtask highlight-check --dump <language> <file>:调试辅助

对任意文件逐 span 打印获胜捕获,用于编写断言时发现确切的 @capture 名。输出格式为 scope<TAB>"文本",跳过纯空白 span(实现见 xtask/src/main.rs)。

从实现上补充两点细节(见 xtask/src/main.rs):

  • 该工具会扫描全部语言查询文件中出现的捕获名(highlights.scmlocals.scm),把"每个捕获名映射到它自己"喂给高亮器,从而直接读回获胜的 @capture 原始名字,无需手工维护 scope 列表;其中 local.definition.* 前缀的 locals 捕获会被解析为引用实际应用的高亮(local. 前缀名除外);
  • 高亮失败(语法规格未构建)时,corpus 模式会打印 skipped 并跳过而非 panic,允许只构建部分语法的开发环境运行对应语言的检查。

小结:编写高亮查询的自检清单

结合手册与仓库实践,编写或修改 highlights.scm 时可按以下清单自检:

  1. 文件位置正确:runtime/queries/{language}/highlights.scm
  2. 每个捕获选了最具体的 scope(@function.method vs @function@variable.other.member),完整清单参照 book/src/themes.md
  3. 共享规则在前、覆盖规则在后;需要覆盖嵌套节点时,把捕获放在叶子节点上;
  4. 使用 ; inherits: 复用基础语言查询时,确认所有捕获在每个继承它的语法中都合法;
  5. 语法无法区分的 scope 用 #match? 谓词(如大小写正则)做启发式过滤;
  6. 先跑 cargo xtask query-check <language> 验证合法性;
  7. 再为关键优先级场景在 tests/query/highlights/<language-id>/ 下添加 caret 断言语料,跑 cargo xtask highlight-check <language> 回归验证;
  8. 遇到不确定的捕获名,用 cargo xtask highlight-check --dump <language> <file> 打印真实获胜结果。

这样即可保证贡献的高亮查询既合法、又在真实高亮器中产生符合预期的着色结果。

登录后查看全文
热门项目推荐
相关项目推荐