当你在编程时全身心投入,处于心流状态时,你考虑程序时会从其逻辑结构出发,而不是其文本表示。一个好的代码编辑器应该帮助你保持这种状态,让你清晰地看到代码结构并对其进行操作,而不必担心单个字符。
像许多其他编辑器一样,Zed 使用语言服务器协议(Language Server Protocol)来实现几种标准的语言智能功能。但还有许多其他功能需要编辑器本身在语法层面理解代码。在这篇文章中,我将解释 Zed 如何实现语法感知的编辑,并展示 Zed 的一些语法相关功能是如何实现的。
Tree-sitter
在编辑器中解析代码面临一些挑战,而大多数解析器并未为此设计。源代码频繁更改,需要快速重新解析。通常,代码在你编辑的区域是临时无效的,但你仍然需要正确解析文件的其余部分。有许多不同的语言需要解析,并且编辑器的所有功能都需要在所有这些语言中保持一致。
这就是为什么我花了几年时间编写 Tree-sitter,一个开源的解析框架,它能在这些约束下工作。Tree-sitter 使用上下文无关文法的精确形式,通过一种称为广义 LR 解析(或 GLR)的算法解析代码,这使得编写几乎任何编程语言的文法成为可能。它使用增量解析来在编辑后高效地重新解析,并具有一种新颖的错误恢复技术,即使文件处于无效状态,解析器也能产生有用的结果。
使用具体语法树
当你使用 Tree-sitter 解析文件时,它会生成一个名为具体语法树(CST)的数据结构,它类似于抽象语法树,但保留了文档中每个标记位置的信息。这对于你查看和操作代码本身而不仅仅是提取其含义的应用程序非常重要。但是,一旦我们有了表示源文件结构的具体语法树,我们如何才能使用这个 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 解析器和一组查询。
语法高亮
良好的语法高亮能一致地使用颜色来表示文件中每个标记的作用。历史上,大多数代码编辑器都是使用正则表达式进行语法高亮的,这些正则表达式每次操作一行文本,并以近似的方式识别某些独特的代码结构。这种方法通常会产生令人沮丧的不一致高亮。
使用语法树,我们可以更精确、更一致地高亮代码。对于每种语言,我们只需要指定主题中的各种样式和颜色如何映射到语法树中的不同节点。
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.method 的特定颜色,则只以 function 颜色显示)。
通过这种方法,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 中构建了坚实的语法感知基础,我第一次拥有了一个真正符合我期望的工具。利用这些功能,我们还有很大的空间去探索如何进一步改善代码编辑体验。感谢您的阅读!