← 返回博客

Zed 中的可扩展语言支持 - 第 1 部分

2024 年 4 月 18 日


大约两个月前,@maxdeviant 和我开始了使 Zed 具有可扩展性的项目。在代码编辑器中,可扩展性可以包含许多需要不同功能的特性,但在项目的第一阶段,我们专注于可扩展的**语言支持**。我们希望人们能够使用 Zed 以任何编程语言进行编码,而不仅仅是一些选定的语言。我们刚刚达到了第一个里程碑,所以现在是分享我们所做的事情的好时机。

Zed 中的语言支持

Zed 有两种我们使其可扩展的语言特定功能类别

  1. 基于 Tree-sitter 对单个源文件进行进程内基于语法的分析。 这需要每个支持的语言都有一个 Tree-sitter 语法,以及一组 Tree-sitter *queries*,用于描述如何使用该语法的语法树来执行语法突出显示、自动缩进等任务。 有关 Zed 如何使用 Tree-sitter 查询的更多信息,请参见我之前的博客文章

  2. 通过 语言服务器协议提供语义理解的外部服务器。 这需要指定如何运行给定的语言服务器,如何安装和升级该服务器,以及如何调整其输出(补全和符号)以匹配 Zed 的样式。

在第一篇文章中,我将专注于 Tree-sitter 部分。我们将在后续博客文章中介绍如何处理可扩展的语言服务器。

打包解析器的挑战

让扩展程序向 Zed 添加 Tree-sitter 解析器的难点在于 Tree-sitter 解析器表示为 *C 代码*。 语法是用 JavaScript 编写的,并通过 Tree-sitter CLI 转换为 C 代码。 Tree-sitter 这样设计的原因有很多。 简而言之,需要某种图灵完备的语言,而 C 代码具有有用的属性,即可以通过 C 绑定从几乎任何高级语言使用它。 但遗憾的是,C 代码**不是**分发给最终用户最方便的工件。

一种可能的分发扩展的方法是交付 C 代码本身,在用户安装扩展时使用他们的 C 编译器在用户的机器上编译它,然后动态加载生成的共享库。 这基本上就是我们为 Atom 所做的事情(使用 Node.js 打包工具)。 其他使用 Tree-sitter 的编辑器,如 Neovim 和 Helix,也使用相同的方法。

但是对于 Zed,我们想要一个流畅、安全的插件安装体验,而不依赖于用户的 C 编译器。 我们想让扩展程序无法使 Zed 崩溃。 Tree-sitter 解析器主要由自动生成的 C 代码组成,这些代码相当安全,但语法作者也可以编写包含任意逻辑的*外部扫描器*,并且我们已经看到由于第三方外部扫描器中的错误而导致的崩溃。 如果我们直接将扩展作为共享库加载,我们将永远无法防止这些类型的崩溃。

显然,我们使用了 WebAssembly。但如何使用?

你可能不会惊讶地发现该解决方案涉及 WebAssembly。我已经构建了Tree-sitter 的 WebAssembly 绑定,它允许你通过 JavaScript API 在 Web 上运行 Tree-sitter 解析器。 WebAssembly(或简称 wasm)是分发解析器的绝佳格式,因为它具有跨平台性,并且专为安全运行不受信任的代码而设计。

然而,*如何*在我们的原生代码编辑器中使用解析器的 wasm 构建并不明显。

当你运行 wasm 程序时,你的应用程序会为该程序提供一个字节数组,该数组将用作其*线性内存*。 然后,wasm 代码只能读取或写入*这些*字节。 但在 Zed 中,当运行解析器时,需要交换大量数据。 在每次按键时,Zed 需要传入源代码,并且解析器需要返回一个*具体语法树* - 一种比相应文本大得多的数据结构。 在增量解析时,每个语法树都与先前的语法树共享公共结构,并且 Zed 经常将这些语法树发送到后台线程,在这些线程中,它们用于各种异步任务。

因此,如果我们*完全*通过 wasm 运行解析器,我们需要在每次解析时从 wasm 内存中复制大量数据。 因此,仅仅将 Tree-sitter 和语法编译为 wasm 是不够的。

If we simply compiled all of Tree-sitter to wasm, it would be very expensive to copy the syntax tree out of the wasm memory.
如果我们只是简单地将所有 Tree-sitter 编译为 wasm,那么从 wasm 内存中复制语法树将非常昂贵。

混合原生 + WebAssembly 系统

我们决定利用 Tree-sitter 解析器是*表驱动*的事实。 大多数生成的 C 代码由表示状态机的静态数组组成。

与其他表驱动解析框架一样,Tree-sitter 的解析分为两部分。 *词法分析*阶段逐字符处理文本,生成标记。 每个语法的词法分析器都实现为一些自动生成的 C 函数和一些可选的手写函数。 *解析*阶段更为复杂,并且是实际构造语法树的地方。 至关重要的是,解析完全由静态数据驱动。

The lexing logic for a grammar is encoded as executable code, while the parsing logic is encoded as static data.
语法的词法分析逻辑被编码为可执行代码,而解析逻辑被编码为静态数据。

两个阶段之间的这种划分实现了一种独特的架构,其中我们从 wasm 文件加载解析器,但是 wasm 文件中的大多数静态数据都从 wasm 线性内存*复制出来*到原生数据结构中。 在解析期间,当我们需要运行词法分析函数时,我们使用 WebAssembly 引擎,但是所有其余的计算都是*原生*完成的,其方式与使用原生编译的 Tree-sitter 解析器时完全相同。

Tree-sitter uses wasm internally, just for the lexing step. The syntax tree is constructed natively.
Tree-sitter 在内部使用 wasm,仅用于词法分析步骤。 语法树是原生构建的。

词法分析是解析过程中成本最低的部分,也是唯一涉及自定义手写代码的部分。 因此,在许多方面,这种混合原生 + wasm 设计为我们提供了安全性和性能的理想组合。

扩展 Tree-sitter API

为了启用这种新的解析方法,我们向 Tree-sitter 库添加了一些新的原语。

作为背景,Tree-sitter 核心库提供了一个 Parser 类型,用于解析源代码,创建 Tree 对象。要使用解析器,您必须为其分配一个 Language,这是一个从特定语法生成的 opaque 对象,并作为该语法的单独库提供。

let mut parser = tree_sitter::Parser::new();
let python_language = tree_sitter_python::language().unwrap();
 
parser.set_language(&python_language).unwrap();
let tree = parser.parse("def secret():\n  return 42\n", None).unwrap();

我们添加了一个名为 WasmStore 的新类型,它与 Wasmtime wasm 引擎集成,让您可以从 WASM 二进制文件创建 Language 实例。这些语言对象的工作方式与普通的 native 语言对象完全相同,不同之处在于,在使用它们时,您的解析器需要分配一个 WasmStore。这是必需的,因为 wasm 存储允许解析器在词法分析期间调用 wasm 函数。

let engine = wasmtime::Engine::default();
let mut wasm_store = tree_sitter::WasmStore::new(&engine);
 
const WASM_BYTES: &[u8] = include_bytes!("tree-sitter-python.wasm");
 
let python_language = wasm_store.load_language("python", WASM_BYTES).unwrap();
 
parser.set_wasm_store(wasm_store);
parser.set_language(&python_language).unwrap();

除了这一个区别之外,从 WASM 加载的语言的行为与 natively-compiled 语言的行为完全相同。生成的语法树是相同的,并且不与 wasm 存储耦合。

实现亮点

与 Tree-sitter 库的其余部分一样,这些新 API 是用 C 语言实现的。它们使用 Wasmtime 出色的 C API。您可以在这里找到它们的完整实现。

使用 wasm 时,您可以非常低级别地控制模块的链接和加载方式的详细信息。模块为几个常量声明导入,这些常量控制 wasm 线性内存的布局——应该放置其静态数据的地址、调用堆栈的基地址以及堆的起始地址。这是一个关于 tree-sitter WasmStore 的 wasm 内存如何布局的图示

The layout of a Tree-sitter wasm store's linear memory
Tree-sitter wasm 存储的线性内存的布局

模块还为它们所依赖的所有函数声明导入。如上所述,Tree-sitter 语法可以包含手写的源文件,称为外部扫描器。这些文件通常使用 C 标准库中的函数 - 字符分类函数,如 iswalpha/iswspace,字符串处理函数,如 strlen/strncmp,以及内存管理函数,如 malloc/free,用于携带少量状态。为了处理像这样的导入,Tree-sitter 库嵌入了一个小的 wasm blob,其中包含 libc 中的函数子集,这些函数可用于外部扫描器。

提供我们自己的 mini-libc 的一个很酷的地方在于,我们不需要使用内存分配函数 mallocfree 等的标准版本。我们知道外部扫描器分配的内存只需要在单个解析期间使用,因此我们实现了我们自己的微型 malloc 库,该库使用 bump-allocation。此分配器的开销远低于通用 malloc 实现,并且需要的 wasm 代码少得多。最重要的是,它使外部扫描器无法导致内存泄漏!我们只需在每次解析开始时重置整个 wasm 堆即可。

使用语言扩展

我们一发布此功能,Zed 社区就开始发布语言扩展。Zed 的扩展商店中当前可用的语言扩展数量为 6️⃣7️⃣ 个并且还在不断增加。这些语言支持 Zed 的所有语法感知功能:语法感知选择、大纲视图、基于语法的自动缩进,当然还有语法高亮。

要浏览扩展,请在应用程序菜单中单击 Zed > 扩展。如果您使用的语言尚未得到支持,我们邀请您在我们的 扩展存储库 上打开一个 PR。或者只是打开一个 issue 并寻求帮助。请参阅 “开发扩展”文档 以开始使用。

结束语

使用 wasm 来打包 Tree-sitter 语法对 Zed 来说非常有效,并且 Wasmtime 引擎的使用体验非常棒。Tree-sitter 语法只是 Zed 扩展系统的一部分。在以后的文章中,我们将讨论我们使用 wasm 使 Zed 可扩展的其他方式。感谢您的阅读!