Helix tags.scm 完全指南:基于语法树的符号选择器与标签查询编写
Helix 在无 LSP 参与的情况下,就能为纯文本/任意有 tree-sitter 语法的语言提供文档级与工作区级的符号选择器(symbol picker),其核心机制就是 tags.scm 标签查询。本文以官方指南 book/src/guides/tags.md 为主体,完整讲解 tags 查询的存放位置、@definition.* / @name / @reference.* 三类捕获(captures)的语义,并结合仓库中的真实查询文件与 helix-core、符号选择器实现 的源码,说明一个符号从“查询命中”到“出现在选择器列表中”的完整链路,读完即可为任何语言手写或修正 tags.scm。
什么是 tags 查询:LSP 式能力的纯语法实现
Helix 开箱即用地提供类似 LSP 的功能——文档符号选择器与工作区符号选择器。这些功能对任何满足两个条件的语言都可用:
- 该语言拥有 tree-sitter 语法(grammar);
- 该语言拥有
tags.scm查询文件,用模式匹配(pattern matching)从语法树中“抠出”感兴趣的节点。
这正是 tree-sitter 官方文档中所谓“Code Navigation Systems(代码导航系统)”的思路:不依赖语言服务器,直接用查询语言声明“什么是定义、什么是引用”。Helix 中该机制的入口在 helix-core/src/syntax.rs:
/// Compiles the tags.scm query for a language.
pub fn compile_tag_query(
grammar: Grammar,
config: &LanguageConfiguration,
) -> Result<Option<TagQuery>> {
let name = &config.language_id;
let text = read_query(name, "tags.scm");
if text.is_empty() {
return Ok(None);
}
// ...
}
从这段实现可以看出两点事实:
- 查询按语言 ID 从 runtime 目录读取
tags.scm;文件不存在或为空时静默返回None,即该语言没有 tags 功能但不会报错; - 编译时通过白名单校验允许的谓词(见下文“支持哪些谓词”一节),未知谓词会触发
InvalidPredicateError,并最终以Failed to compile tags.scm query for '{name}'的形式记入日志(编译失败被log::error!捕获,不会中断编辑器运行)。
查询编译成功后缓存在语言配置中,实际执行时由 Syntax::tags() 驱动查询迭代器,在每个语法层上运行 tags.scm 并产出匹配事件:
pub fn tags<'a>(
&'a self,
source: RopeSlice<'a>,
loader: &'a Loader,
range: impl RangeBounds<u32>,
) -> QueryMatchIter<...> {
QueryMatchIter::new(
&self.inner,
source,
|lang| loader.tag_query(lang).map(|q| &q.query),
range,
)
}
查询文件放在哪里
向 Helix 贡献或本地实验 tags 查询时,放置位置有两处:
- 仓库内(贡献路径):
runtime/queries/{language}/tags.scm。当前仓库runtime/queries/下已为数百种语言提供了查询,例如 rust/tags.scm、python/tags.scm、bash/tags.scm; - 本地 runtime 目录(实验路径):如 Linux 上的
~/.config/helix/runtime/queries/{language}/tags.scm,便于在不改动仓库的前提下测试自己的查询。
捕获(Captures)详解
tags 查询的语义完全由捕获决定。官方文档定义了以下三类捕获。
@definition.*:标记符号定义
@definition.* 将某个节点标记为一个符号定义。目前被识别的定义捕获只有下面 11 种:
| Capture name |
|---|
definition.class |
definition.constant |
definition.enum |
definition.field |
definition.function |
definition.interface |
definition.macro |
definition.module |
definition.section |
definition.struct |
definition.type |
这个名单不是文档的“口头约定”,而是硬编码在符号选择器的解析逻辑中的。helix-term/src/commands/syntax.rs 中的 TagKind::from_name 精确枚举了这些种类:
fn from_name(name: &str) -> Option<Self> {
match name {
"class" => Some(TagKind::Class),
"constant" => Some(TagKind::Constant),
"enum" => Some(TagKind::Enum),
"field" => Some(TagKind::Field),
"function" => Some(TagKind::Function),
"interface" => Some(TagKind::Interface),
"macro" => Some(TagKind::Macro),
"module" => Some(TagKind::Module),
"section" => Some(TagKind::Section),
"struct" => Some(TagKind::Struct),
"type" => Some(TagKind::Type),
_ => None,
}
}
因此文档中的关键约束在代码里有直接对应:捕获名不在上表中的 @definition.* 会被符号选择器忽略(from_name 返回 None 后该匹配被跳过)。编写查询时,应把语言特有结构映射到最接近的已列种类,而不是自造新种类。一个现成的佐证是 runtime/queries/go/tags.scm 中的 @definition.method——由于 method 不在识别列表中,这类捕获实际上不会进入选择器结果;这提醒贡献者在提交查询前先自查捕获名。
@name:标记定义中的名称标识符
@definition.* 应捕获整个定义节点,而同一匹配内的 @name 应捕获其中的名称标识符节点。官方给出的示例:
(function_definition
name: (identifier) @name) @definition.function
(class_definition
name: (identifier) @name) @definition.class
@name 的作用是决定选择器中显示什么名字、以及模糊匹配针对什么文本。这一“回退”语义在源码 tags_iter 中可以清楚看到:
// Find the @definition.* and optional @name captures in this match.
let mut def_capture = None::<(TagKind, std::ops::Range<u32>)>;
let mut name_range = None::<std::ops::Range<u32>>;
let name_capture = query.get_capture("name");
for node in mat.nodes.iter() {
let capture_name = query.capture_name(node.capture);
if let Some(kind) = capture_name
.strip_prefix("definition.")
.and_then(TagKind::from_name)
{
def_capture = Some((kind, node.node.byte_range()));
} else if name_capture == Some(node.capture) {
name_range = Some(node.node.byte_range());
}
}
let Some((kind, def_byte_range)) = def_capture else {
continue;
};
let name_byte_range = name_range.unwrap_or_else(|| def_byte_range.clone());
这段代码说明:
- 每个匹配中必须存在一个可识别的
@definition.*,否则整个匹配被丢弃(没有定义的匹配不可能产出符号); @name是可选的——缺失时符号名与跳转位置直接取定义节点本身。例如 bash/tags.scm 仅有一行(function_definition name: (word) @definition.function),把定义直接打在word(即函数名)上,靠回退逻辑工作得很好。
@reference.*:标记调用点与类型引用
@reference.* 将节点标记为调用点或类型引用,供工作区符号搜索用于定位“用法”(usages)。最常见的变体是 @reference.call 与 @reference.class,其中同样用 @name 捕获标识符:
(call
function: (identifier) @name) @reference.call
Helix 的工作区符号选择器(syntax_workspace_symbol_picker,与文档符号选择器同位于 helix-term/src/commands/syntax.rs)会遍历当前工作区内的文件并构建符号索引,reference 捕获就是这条链路中定位调用位置的数据来源。
仓库中的真实 tags.scm 示例精读
官方文档指出可以在 Helix 仓库中检索 tags.scm 作为示例。下面结合仓库内四个代表性文件说明各种写法。
Rust:定义种类齐全
runtime/queries/rust/tags.scm 展示了把 Rust 语言结构映射到标准种类的技巧:
(struct_item
name: (type_identifier) @name) @definition.struct
(const_item
name: (identifier) @name) @definition.constant
(trait_item
name: (type_identifier) @name) @definition.interface
(function_item
name: (identifier) @name) @definition.function
(function_signature_item
name: (identifier) @name) @definition.function
(enum_item
name: (type_identifier) @name) @definition.enum
(enum_variant
name: (identifier) @name) @definition.struct
(type_item
name: (type_identifier) @name) @definition.type
(mod_item
name: (identifier) @name) @definition.module
(macro_definition
name: (identifier) @name) @definition.macro
注意两个“就近映射”的范例:trait 映射到 definition.interface(而非自造 definition.trait);枚举变体映射到 definition.struct。这正是文档“映射到最接近种类”要求的实例。
Python:定义 + 引用同文件共存
runtime/queries/python/tags.scm 全文(含注释掉的一行常量写法)很短,是定义与引用捕获共存的典型:
(module (expression_statement (assignment left: (identifier) @name) @definition.constant))
(class_definition
name: (identifier) @name) @definition.class
(function_definition
name: (identifier) @name) @definition.function
(call
function: [
(identifier) @name
(attribute
attribute: (identifier) @name)
]) @reference.call
这里展示了两种常见技巧:
- 模块级常量的复合模式:
module > expression_statement > assignment三层嵌套,用于识别X = ...形式的模块常量,且把外层assignment标为定义、内层identifier标为名字; - 引用捕获的多候选写法:
[ ... ]选择列表让@reference.call同时匹配裸函数名调用和obj.method()属性调用,且两种候选都把@name打在真正的方法/函数名上——保证工作区搜索时匹配的是短名而非完整属性链。
Go:利用 @doc 捕获与谓词
runtime/queries/go/tags.scm 展示了 tags 查询独有的文档注释增强写法:
(
(comment)* @doc
.
(function_declaration
name: (identifier) @name) @definition.function
(#strip! @doc "^//\\s*")
(#select-adjacent! @doc @definition.function)
)
(
(comment)* @doc
.
(method_declaration
name: (field_identifier) @name) @definition.method
(#strip! @doc "^//\\s*")
(#select-adjacent! @doc @definition.function)
)
(call_expression
function: [
(identifier) @name
(parenthesized_expression (identifier) @name)
(selector_expression field: (field_identifier) @name)
(parenthesized_expression (selector_expression field: (field_identifier) @name))
]) @reference.call
它用 @doc 捕获紧邻定义上方的连续注释,配合 #strip! 去掉每行 // 前缀、#select-adjacent! 把注释与定义视为一个相邻组。注意这两个谓词(以及 local 属性)正是 compile_tag_query 白名单中明确放行、且注释标注为“允许但尚未启用”的扩展能力——写查询时可以使用它们,但不要使用其他自定义谓词,否则编译会因 InvalidPredicateError::unknown 失败。
从匹配到选择器列表:一次完整的调用链
把源码串起来,tags 查询的消费链路是:
- 加载与编译:
LanguageConfiguration初始化时调用 compile_tag_query,读取runtime/queries/{language}/tags.scm并编译为TagQuery;空文件得到None。 - 迭代匹配:Syntax::tags() 在给定字节范围内运行查询,产出逐匹配的
QueryMatchIter。 - 提取符号:tags_iter 从每个匹配中提取
@definition.*(必需)与@name(可选,缺失时回退到定义范围),组装出带kind、name、起止字节/行号与所属文档的Tag结构。若调用方给了正则模式(例如文档符号选择器的过滤输入),过滤是对@name范围(而非定义范围)做正则匹配——这也是“@name应只包含标识符”的另一层原因。 - 展示与跳转:文档符号选择器 syntax_symbol_picker 以“kind + name”两列构建 Picker,选中后将光标
Selection设置为tag.start..tag.end(即整个定义节点的字节范围,而非仅名字范围)并居中视图;工作区符号选择器syntax_workspace_symbol_picker则复用同一套 tags 索引,从当前文档所在工作区(find_workspace_in)遍历文件完成全局搜索。
编写 tags.scm 的实操清单
基于文档约束与源码行为,为某语言编写/审查 tags.scm 时建议按以下清单检查:
- 位置:贡献放
runtime/queries/{language}/tags.scm;本地实验放~/.config/helix/runtime/queries/{language}/tags.scm; - 定义捕获:只使用 11 种已识别的
@definition.{class,constant,enum,field,function,interface,macro,module,section,struct,type},语言特有结构就近映射,不要发明新种类(不识别的会被选择器静默忽略); - 命名分离:
@definition.*打在整个定义节点上,@name打在其中的标识符节点上;如果节点本身就是标识符,也可以省略@name(靠回退逻辑); - 引用捕获:需要支持用法搜索时补充
@reference.call/@reference.class等,并同样用@name标记短名; - 谓词:只用
#strip!、#select-adjacent!(及local属性)这些白名单内谓词,其余谓词会导致查询编译失败; - 验证:修改后重启 Helix(或重载 runtime),用文档符号选择器确认符号按预期出现,用工作区符号选择器确认引用可被检索。
小结
tags 查询是 Helix 把 tree-sitter 语法树直接转化为“符号导航”能力的机制:tags.scm 用三个捕获族声明定义、名字与引用,helix-core/src/syntax.rs 负责按语言加载与编译查询,helix-term/src/commands/syntax.rs 把匹配转化为可选择、可跳转的符号列表。掌握本文的捕获语义、放置路径与谓词白名单后,你既能为现有语言修正遗漏的符号种类,也能为自己常用的新语言补上完整的 tags 支持。
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 StartedRust0622
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