首页
/ Helix tags.scm 完全指南:基于语法树的符号选择器与标签查询编写

Helix tags.scm 完全指南:基于语法树的符号选择器与标签查询编写

2026-09-05 11:10:26作者:范垣楠Rhoda

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 的功能——文档符号选择器工作区符号选择器。这些功能对任何满足两个条件的语言都可用:

  1. 该语言拥有 tree-sitter 语法(grammar);
  2. 该语言拥有 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.scmpython/tags.scmbash/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 查询的消费链路是:

  1. 加载与编译LanguageConfiguration 初始化时调用 compile_tag_query,读取 runtime/queries/{language}/tags.scm 并编译为 TagQuery;空文件得到 None
  2. 迭代匹配Syntax::tags() 在给定字节范围内运行查询,产出逐匹配的 QueryMatchIter
  3. 提取符号tags_iter 从每个匹配中提取 @definition.*(必需)与 @name(可选,缺失时回退到定义范围),组装出带 kindname、起止字节/行号与所属文档的 Tag 结构。若调用方给了正则模式(例如文档符号选择器的过滤输入),过滤是对 @name 范围(而非定义范围)做正则匹配——这也是“@name 应只包含标识符”的另一层原因。
  4. 展示与跳转:文档符号选择器 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 支持。

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
982
503
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384