Helix 彩虹括号原理与实践:如何为任意语言编写 rainbows.scm 查询
本文以 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.rs 中scope_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 而言,alternation、group、named_node、predicate、wildcard_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 正是这么做的,并额外覆盖了 doctype、erroneous_end_tag、script_element、style_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.scope 与 rainbow.bracket 两个捕获,其他自定义谓词则一律视为未知谓词而报错——也就是说 rainbows.scm 是一个语义被严格限定的查询文件。
彩虹颜色是怎么算出来的
理解了查询文件后,Syntax::rainbow_highlights 展示了完整的渲染调用链:
- 用当前语言的
rainbow_query构建QueryMatchIter,按文档范围迭代匹配事件; - 遇到
@rainbow.scope捕获时压栈,颜色由scope_stack.len() % rainbow_length决定——即嵌套层数对"彩虹长度"取模,形成循环用色。rainbow_length对应主题中彩虹色板的长度; - 遇到
@rainbow.bracket捕获时,取栈顶 scope:若该 scope 未开启include-children(即node字段为Some),则要求括号节点的父节点恰为该 scope 节点,否则不产生高亮;满足则把括号字节范围换算成字符边界,压入 highlights; - 在每个新捕获开始前,弹出所有已结束的 scope(
byte_range.start >= scope.end),保证颜色栈与文档结构同步。
编译层面,rainbows.scm 通过 read_query + RainbowQuery::new 惰性加载并缓存进 OnceCell,每个语言只编译一次;语言目录中没有 rainbows.scm 时 rainbow_query 返回 None,该语言不做彩虹高亮。
相关配置与调试工具
- 按语言开关:
languages配置中的rainbow_brackets键可覆盖全局editor.rainbow-brackets(见 helix-core/src/syntax/config.rs)。 - 调试利器:
:tree-sitter-subtree命令会以 S 表达式形式展示主选区下的语法树,是判断"该把哪些节点写成 scope、哪些字面量写成 bracket"的必备工具。 - 仓库内可直接参考的现成范例包括 runtime/queries/rust/rainbows.scm、runtime/queries/go/rainbows.scm、runtime/queries/json/rainbows.scm 等,覆盖约 90 种语言,是编写新语言查询时最好的"对照答案"。
小结
为 Helix 添加彩虹括号的工作可以归纳为四步:
- 在
runtime/queries/<lang>/rainbows.scm中用字面量字符串捕获所有括号符号为@rainbow.bracket; - 歧义符号(如
<>)只在泛型/类型参数等限定节点内捕获,并同时标记为@rainbow.scope; - 查阅该语言
grammar.js,把定义中含字面量括号的节点捕获为@rainbow.scope; - 若换色节点不是括号的直接父节点(典型如 HTML),用
(#set! rainbow.include-children)放宽直接后代检查。
结合 :tree-sitter-subtree 观察真实语法树,并对照 helix-core/src/syntax.rs 的栈式着色逻辑,即可为任意新语言写出准确、可运行的彩虹括号查询。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00