当您沉浸在编程的流畅状态中时,您会根据程序的逻辑结构来思考,而不是它的文本表示。一个好的代码编辑器应该帮助您保持这种状态,让您清楚地看到代码的结构并操作它,而无需担心单个字符。
像许多其他编辑器一样,Zed 使用语言服务器协议来实现几种标准类型的语言智能。但是,还有许多其他功能需要编辑器本身在语法级别理解代码。在这篇文章中,我将解释 Zed 如何处理语法感知编辑,并展示 Zed 的一些与语法相关的功能是如何实现的。
Tree-sitter
在编辑器中解析代码提出了一些大多数解析器并非旨在解决的挑战。源代码频繁更改,需要快速重新解析。通常,代码在您编辑的区域中暂时无效,您仍然需要正确解析文件的其余部分。有许多不同的语言需要解析,并且编辑器的所有功能都需要在所有这些语言中保持一致。
这就是我花了好几年时间编写 Tree-sitter 的原因,它是一个开源解析框架,可以在这些约束条件下工作。Tree-sitter 使用上下文无关文法的精确形式主义解析代码,使用一种称为广义 LR 解析(或 GLR)的算法,这使得编写几乎任何编程语言的语法成为可能。它使用增量解析来允许在编辑后进行有效的重新解析,并具有一种新颖的错误恢复技术,即使文件处于无效状态,解析器也可以产生有用的结果。
使用具体语法树
当您使用 Tree-sitter 解析文件时,它会生成一个名为具体语法树(或 CST)的数据结构,它类似于抽象语法树,但它保留了文档中每个 token 位置的信息。这对于您正在查看和操作代码本身,而不仅仅是提取其含义的应用程序非常重要。但是,一旦我们有了一个表示源文件结构的具体语法树,我们如何使用该 CST 来改善代码编辑体验呢?
CST 的一个简单应用是一个名为语法感知选择的功能,它包含编辑器中的两个命令:“选择更大的语法节点”和“选择更小的语法节点”,默认情况下绑定到键alt-up
和alt-down
。这些命令让您可以快速选择您想要更改的代码,而无需逐个字符地移动光标。
当您尝试使用多个光标一次编辑多个代码片段时,这些命令特别有用。通常,您想要编辑的代码片段长度不同,但在语法上相同。

为了实现这些命令,我们不需要除具体语法树本身之外的任何信息,因为我们不关心特定类型的语法节点,我们只需要知道文档中的哪些范围对应于语法树中的节点。但是,对于大多数其他语法感知功能,我们需要有关节点的更多详细信息。让我们讨论一下这些功能是如何工作的。
树查询
在许多代码分析任务中,我们需要在语法树中搜索一组特定的结构模式。为了使这种类型的任务更容易,Tree-sitter 包含一个查询引擎。树查询允许您使用一种简单的声明性语言编写语法模式,并有效地迭代语法树中与这些模式匹配的所有节点组。
这是一个 JavaScript 语法树的示例查询。在此查询中,我们正在寻找三种不同的模式。第一个模式匹配将箭头函数分配给对象属性的任何操作(例如a.b = (c) => d
)。第二个模式匹配对this
属性的任何引用(例如this.x
)。最后一个模式匹配任何标识符。
(assignment_expression
left: (member_expression
property: (property_identifier) @the_assigned_method)
right: (arrow_function))
(member_expression
object: (this)
property: (property_identifier) @the_property_of_this)
(identifier) @the_variable
在查询中,以@
符号开头的名称(例如@the_assigned_method
和@the_property_of_this
)是任意的。它们被称为捕获,您可以使用它们将给定的名称分配给模式中的前一个节点。每次该模式匹配时,您都可以使用这些捕获名称来检索相应的匹配节点。
当您构造一个查询时,Tree-sitter 会将查询的源代码编译成一个紧凑的状态机。状态机的结构使得可以在语法树的单个深度优先遍历中找到查询的所有匹配项。在执行查询期间,我们遍历语法树并维护一组正在进行的匹配项。当步入树中的每个节点时,我们会推进任何其下一步骤由该节点满足的匹配项,删除任何失败的匹配项,并将任何已完成的匹配项报告给调用应用程序。
查询语言具有其他功能,可以表达更复杂的模式。有关更多信息,请参见 Tree-sitter 文档的查询部分。
Zed 使用树查询来表达处理语法树的所有特定于语言的规则。这使得集成一种新语言相对简单:无需为每种语言编写自定义过程代码:我们只需添加一个 Tree-sitter 解析器和一组查询。
语法高亮
良好的语法高亮使用一致的颜色来指示文件中每个 token 的角色。从历史上看,大多数代码编辑器都使用正则表达式来执行语法高亮,正则表达式一次在一行文本上操作,并以近似的方式识别某些独特的代码结构。这种方法通常会产生令人沮丧的不一致的高亮显示。
使用语法树,我们可以更精确和一致地高亮显示代码。对于每种语言,我们只需要指定主题中的各种样式和颜色如何映射到语法树中的不同节点即可。
Zed 使用树查询来指定此映射。高亮查询使用与主题中样式名称匹配的捕获名称。例如,这是一个 JavaScript 的示例高亮查询
["do" "for" "while"] @keyword
(function
name: (identifier) @function)
(pair
key: (property_identifier) @function.method
value: [(function) (arrow_function)])
在此查询中,关键字do
、for
和while
将出现在主题的keyword
颜色中,函数声明的名称将出现在function
颜色中。值是函数的对象字面量键将出现在function.method
颜色中(或者只是function
颜色,如果主题没有为function.method
指定特定颜色)。
使用这种方法,Zed 主题可以非常简单,并且与任何特定语言分离。这是一个来自 Zed 的“One Dark”主题的片段
{
"keyword": {
"color": "#B478CF"
},
"function": {
"color": "#74ADE9"
},
"emphasis.strong": {
"color": "#C0966B",
"weight": "bold"
},
"link_text": {
"color": "#74ADE9",
"italic": false
}
}
符号轮廓
符号大纲通过列出文件中定义的符号,并展示它们的层级关系,使代码更易于导航。在 Zed 中,我们以两种方式呈现文件的大纲。面包屑始终在编辑器顶部可见,并列出包含光标位置的符号。而大纲模态框列出文件的所有符号,并允许您进行模糊过滤,并跳转到文件中的任何符号。
大纲模态框非常强大,因为模糊搜索不仅仅针对一个符号名称进行操作。例如,您可以输入类似 buf lang
的查询,以快速调出所有匹配 lang
并且位于匹配 buf
的父符号内的符号。 这将包括诸如 Buffer::set_language
和 BufferSnapshot::language_at
等方法。

大纲中的项目除了符号名称之外,还包含一些上下文,例如 Rust 中的函数名称 fn
。如果您的模糊查询包含多个单词,您可以在上下文以及名称本身中匹配这些单词。例如,您可以通过键入 pub async fn
来列出文件中所有公共异步函数。
为了使大纲以这种方式工作,并在我们支持的所有语言中感觉统一,我们决定不使用 语言服务器协议的版本来实现此功能。 符号大纲本质上是语法性的,并且局限于一个文件,因此我们可以使用另一个 Tree-sitter 查询来实现它。这使我们可以完全控制大纲的呈现方式。
这是一个来自 Rust 的大纲查询的代码片段
(impl_item
"impl" @context
trait: (_)? @name
"for"? @context
type: (_) @name) @item
(function_item
(visibility_modifier)? @context
(function_modifiers)? @context
"fn" @context
name: (_) @name) @item
在此查询中,@name
捕获标记包含符号名称的语法节点。@item
捕获标记符号的整个定义的节点,这对于确定不同符号之间的嵌套关系非常重要。@context
捕获任何文本应包含在大纲中的节点,以帮助显示符号的定义类型。
自动缩进
编辑器的语言感知能力在编写代码和阅读代码时都发挥作用,其形式为自动缩进。大多数语言都有明确的缩进约定,可以用特定的语法结构来表示,其中应该增加缩进。
Zed 使用第三个查询来表达这些约定。在缩进查询中,每个匹配项用于计算一个缩进范围 - 文件中的一个范围,其中每行都应比范围开始的行缩进一个级别。 @indent
捕获标记定义缩进范围的节点。 可选的 @start
和 @end
捕获可用于缩小缩进范围。
例如,在这个 JavaScript 的缩进查询中,语句块的内容会被缩进,直到包含右大括号的那一行。 几种类型的多行表达式和语句相对于它们开始的行缩进
(statement_block "}" @end) @indent
[
(assignment_expression)
(member_expression)
(if_statement)
] @indent
这个简单的查询允许 Zed 为大多数代码编辑器完全放弃缩进调整的代码正确地自动缩进
if (one)
// this line is indented because it's part of the if statement
two = three.four // this line is indented because it's part of a member expression
.five(() => {
six({
seven: eight,
})
})
// this line is dedented to match the previous statement.
nine()
多语言文档
对于许多语言来说,一个语法树不足以对源文件的整个结构进行建模。 例如,在 HTML 文件中,每个 script
标签都包含一个单独的 JavaScript 文档。 在像 ERB 这样的模板语言中,该文件由一个 HTML 文档和一个 Ruby 文档组成,其中每个文档都被分成许多不相交的范围,这些范围交错在一起。 即使在 Rust 中,传递给每个宏调用的参数也必须重新解析为迷你 Rust 文档,以便理解其中 Rust 表达式和声明的结构。
我们将这些内部语法树称为语言注入(由 TextMate 编辑器创造的术语)。 与之前的所有功能一样,Zed 使用查询来表达每种语言的语言注入规则。 例如,HTML 注入查询中的这个模式表明,脚本标签内部应该被解析为 JavaScript
(script_element
(raw_text) @content
(#set! "language" "javascript"))
管理语法树的集合
在某些语言中,给定缓冲区可能存在大量注入的语法树。 例如,在 Rust 中,文件通常包含许多宏调用,并且我们为每个调用计算单独的 Rust 语法树。
从语法树集合中读取时,我们需要一种有效的方法来检索与文件给定范围相交的语法树。 为了实现这一点,我们将语法树集合存储在写时复制 B 树 数据结构中,该结构称为总和树,我们在 Zed 中广泛使用它来存储顺序集合(我们将在即将发布的博文中进一步讨论)。
当缓冲区被编辑时,我们需要更新语法树集合以反映编辑。 任何与编辑相交的树都需要重新解析。 然后,在每个树中,我们需要找到任何已添加或删除的注入。 为此,我们对受编辑影响的树范围运行注入查询。 新注入的语法树本身可能包含注入,因此这是一个迭代过程!
尽管这个过程很复杂,但由于 Tree-sitter 的增量解析和查询,以及总和树的高效查找和切片操作,这个过程非常快。 即使在异常缓慢的情况下,编辑器也能保持响应,因为总和树使用高效的写时复制结构,使我们即使在数据的先前快照正在主线程上积极使用时,也可以在后台线程上执行此更新!
总结
多年前,当我开始研究 Tree-sitter 时,我梦想有一天可以使用一个轻量级的代码编辑器,它可以在深层理解代码的语法。 现在我们已经在 Zed 中构建了坚实的语法感知基础,我第一次拥有了一个以我想要的方式工作的工具。 我们还有很大的空间来探索如何使用这些功能进一步改善代码编辑体验。 感谢您的阅读!