首页
/ Helix 彩虹括号原理与实践:如何为任意语言编写 rainbows.scm 查询

Helix 彩虹括号原理与实践:如何为任意语言编写 rainbows.scm 查询

2026-09-05 20:47:56作者:齐冠琰

本文以 Helix 官方指南「Adding Rainbow Bracket Queries」为主体,讲解如何用 Tree-sitter 查询为 Helix 添加彩虹括号(rainbow brackets)高亮:两个核心捕获组 @rainbow.scope / @rainbow.bracket 的语义、以 TSQ 语言为例的完整编写流程、rainbow.include-children 属性解决 HTML 类标记语言的嵌套困境,并结合 helix-core/src/syntax.rs 中的源码实现,说明彩虹颜色栈与直接子节点检查的底层原理。

两个捕获组:scope 与 bracket

Helix 通过每种语言对应的 rainbows.scm Tree-sitter 查询文件提供彩虹括号功能。查询文件只使用两个捕获:

  • @rainbow.scope:捕获增加嵌套层级的节点。从源码结构看,scope 捕获会把节点压入一个作用域栈,其子树中的括号按栈深度切换到下一档彩虹颜色(见 helix-core/src/syntax.rsscope_stack.push 的逻辑)。
  • @rainbow.bracket:捕获括号节点本身,用"当前彩虹颜色"为其着色。

一句话概括:@rainbow.scope 让整棵子树切换到下一色,@rainbow.bracket 则把捕获节点染上当前色。

实战:为 TSQ 语言编写彩虹查询

以 Tree-sitter Query(TSQ)语言本身为例,查询文件放在仓库根目录下的 runtime/queries/tsq/rainbows.scm。当前仓库中该文件的实际内容为 runtime/queries/tsq/rainbows.scm

[
  (list)
  (predicate)
  (grouping)
  (named_node)
] @rainbow.scope

[
  "(" ")"
  "[" "]"
] @rainbow.bracket

第一步:捕获括号节点

TSQ 只使用圆括号和方括号,直接写成:

["(" ")" "[" "]"] @rainbow.bracket

指南特别指出:alternation(方括号分组)内节点的排列顺序不会被考虑,即 ["(" ")" ...]["[" "]" "(" ")" ...] 等价。

第二步:小括号要谨慎

<> 在很多语言中同时是比较运算符,全局捕获会把每一个比较表达式都染成彩虹色。正确做法是只在泛型/类型参数节点内捕获,并顺带把该节点标记为 scope:

(type_arguments ["<" ">"] @rainbow.bracket) @rainbow.scope

第三步:命名节点与匿名节点的区别

指南中有一段容易踩坑的说明:语法高亮查询中用括号包围的节点(如 (group))是 named nodes,对应语法(grammar)中规则的命名。而括号类符号在 Tree-sitter 语法里通常写成字面量字符串,例如:

{
  // ...
  arguments: $ => seq("(", repeat($.argument), ")"),
  // ...
}

这些以字面量字符串写入语法的节点,在查询中也必须用相同的带引号字符串来捕获——这就是为什么括号写成了 "(" ")" 而非 (

第四步:从 grammar.js 中寻找 scope 节点

确定 @rainbow.scope 最省力的办法是翻查该语言 tree-sitter 仓库中的 grammar.js:凡是定义中包含字面量括号的节点,通常就是括号的直接父节点,同时也正是希望"进入下一色"的节点。对 TSQ 而言,alternationgroupnamed_nodepredicatewildcard_node 等节点的定义中都含有字面量括号,因此全部捕获为 scope:

[
  (group)
  (named_node)
  (wildcard_node)
  (predicate)
  (alternation)
] @rainbow.scope

这一策略可作为绝大多数编程语言和配置语言的通用经验法则;标记类语言(如 HTML)则更棘手,可能需要额外实验——这正是下一节 rainbow.include-children 属性存在的意义。

rainbow.include-children 属性:解决"括号不是直接子节点"的困境

默认行为:直接后代检查

rainbow.include-children 属性可以施加在 @rainbow.scope 捕获上。默认情况下,一个 @rainbow.bracket 捕获的节点必须是某个 @rainbow.scope 节点的直接后代才会被高亮;该属性可以关闭这项检查,使括号只要是有 scope 节点的直接或间接后代即可着色。

这一"直接子节点"要求在源码中清晰可见:helix-core/src/syntax.rs 中对 bracket_capture 的处理会校验 mat.node.parent() == scope.node,不满足则跳过;而当 scope 模式带有 include-children 时,栈中存的节点为 None,该校验被绕过(helix-core/src/syntax.rs)。

HTML 实例:属性用在哪里

对文档 <a>link</a>,其语法树为:

(element                   ; <a>link</a>
  (start_tag               ; <a>
    (tag_name))            ; a
  (text)                   ; link
  (end_tag                 ; </a>
    (tag_name)))           ; a

若要高亮 <></,把它们捕获为 bracket,并因为 (element) 节点互相嵌套而将其捕获为 scope:

["<" ">" "</"] @rainbow.bracket

(element) @rainbow.scope

但这个组合什么都不会高亮<></(start_tag)(end_tag) 的子节点,而不是 (element) 的直接子节点;又不能把 (start_tag)/(end_tag) 捕获为 scope,因为它们并不嵌套其他元素。解法就是用属性移除"直接子节点"的要求:

((element) @rainbow.scope
 (#set! rainbow.include-children))

设置后,<></ 虽然不是 (element) 的直接后代,也能正常染上彩虹色。仓库中 runtime/queries/html/rainbows.scm 正是这么做的,并额外覆盖了 doctypeerroneous_end_tagscript_elementstyle_element 等节点:

[
  (doctype)
  (erroneous_end_tag)
] @rainbow.scope

([
   (element)
   (script_element)
   (style_element)
 ] @rainbow.scope
 (#set! rainbow.include-children))

["<" ">" "<!" "</" "/>"] @rainbow.bracket

指南的结论是:rainbow.include-children 对绝大多数编程语言没有必要,只有当"提升嵌套层级(换色)的节点"不是括号节点的直接父节点时才需要它。

从源码看,该属性还有一个硬约束:它不接受参数RainbowQuery::new 在编译查询时会拦截 SetProperty { key: "rainbow.include-children", val } 谓词,一旦带了值就报 property 'rainbow.include-children' does not take an argument 错误;同一函数中通过 query.get_capture 固定解析出 rainbow.scoperainbow.bracket 两个捕获,其他自定义谓词则一律视为未知谓词而报错——也就是说 rainbows.scm 是一个语义被严格限定的查询文件。

彩虹颜色是怎么算出来的

理解了查询文件后,Syntax::rainbow_highlights 展示了完整的渲染调用链:

  1. 用当前语言的 rainbow_query 构建 QueryMatchIter,按文档范围迭代匹配事件;
  2. 遇到 @rainbow.scope 捕获时压栈,颜色由 scope_stack.len() % rainbow_length 决定——即嵌套层数对"彩虹长度"取模,形成循环用色。rainbow_length 对应主题中彩虹色板的长度;
  3. 遇到 @rainbow.bracket 捕获时,取栈顶 scope:若该 scope 未开启 include-children(即 node 字段为 Some),则要求括号节点的父节点恰为该 scope 节点,否则不产生高亮;满足则把括号字节范围换算成字符边界,压入 highlights;
  4. 在每个新捕获开始前,弹出所有已结束的 scope(byte_range.start >= scope.end),保证颜色栈与文档结构同步。

编译层面,rainbows.scm 通过 read_query + RainbowQuery::new 惰性加载并缓存进 OnceCell,每个语言只编译一次;语言目录中没有 rainbows.scmrainbow_query 返回 None,该语言不做彩虹高亮。

相关配置与调试工具

小结

为 Helix 添加彩虹括号的工作可以归纳为四步:

  1. runtime/queries/<lang>/rainbows.scm 中用字面量字符串捕获所有括号符号为 @rainbow.bracket
  2. 歧义符号(如 <>)只在泛型/类型参数等限定节点内捕获,并同时标记为 @rainbow.scope
  3. 查阅该语言 grammar.js,把定义中含字面量括号的节点捕获为 @rainbow.scope
  4. 若换色节点不是括号的直接父节点(典型如 HTML),用 (#set! rainbow.include-children) 放宽直接后代检查。

结合 :tree-sitter-subtree 观察真实语法树,并对照 helix-core/src/syntax.rs 的栈式着色逻辑,即可为任意新语言写出准确、可运行的彩虹括号查询。

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