← 返回博客

Zed 扩展的生命周期:Rust、WIT、Wasm

今年早些时候,Zed 推出了扩展功能,增加了在 Zed 中添加更多语言、主题、代码片段和斜杠命令的能力。

这个扩展系统由 MaxMarshall 构建,Max 在 4 月份写了一篇精彩的博客文章,介绍了扩展背后的一些复杂工程挑战以及他们如何克服这些挑战。它还解释了扩展如何作为 WebAssembly (Wasm) 模块运行,以及为什么以及如何在 Wasm 中使用 Tree-sitter。

半年后,这仍然是我对我们扩展工作原理的全部了解——考虑到还有更多需要了解的,这不算多。例如,我知道扩展是用 Rust 编写的,但它们是如何以及何时编译的?我知道扩展可以为 Zed 提供语言服务器,但它*究竟*是如何工作的?以及我们*究竟*是如何在 Zed 中将它们作为 Wasm 模块运行的?这一切是如何协同工作的?我需要知道!

两周前,我终于有机会和 Marshall 和 Max 坐下来,问他们我想知道的关于扩展的一切。为了深入探讨,我问他们:当我在 Zed 中安装一个扩展时,究竟会发生什么?

伴随视频

Zed 扩展的生命周期:Rust、WIT、Wasm

这篇博文附带一个 1 小时的配套视频,其中 Marshall、Max 和 Thorsten 探讨了 Zed 中的扩展,深入研究代码库,了解 Rust、Wasm、WIT 和扩展如何协同工作并在 Zed 中运行。

在此观看视频 →

Life of a Zed Extension: Rust, WIT, Wasm

问题

我用来询问“当我安装这个扩展时会发生什么?”的示例扩展是 zed-metals 扩展。它通过添加 Scala Tree-sitter 解析器、Tree-sitter 查询,以及——对我来说,在那次谈话中更有趣的部分——它还添加了对 Metals 语言服务器的支持,从而为 Zed 增加了对 Scala 的支持。

它*是用 Rust 编写的*!是的,看这里

Screenshot of the single Rust file in the metals-zed repository
metals-zed 仓库中单个 Rust 文件的截图

那个仓库里没有更多东西了。有一个 extension.toml 文件,其中包含有关扩展的元数据、Tree-sitter 查询和这个单个 Rust 文件。

所以我想回答的问题是:当我安装这个扩展时,那个 lib.rs 文件中的 Rust 代码何时、如何以及在哪里编译,当我使用 zed-metals 扩展时,它又是如何在 Zed 中执行的?

我们的对话中,Max 和 Marshall 耐心地向我解释了所有涉及的代码,因此我现在可以报告:我们搞清楚了!

安装扩展

当你打开 Zed,按下 cmd-p/ctrl-p 并输入 zed: extensions。你将看到这个

The zed: extensions view in Zed
Zed 中的 zed:extensions 视图

此扩展视图显示 Zed 中所有可用的扩展以及你已安装的扩展。

那么,首先:这个扩展列表是从哪里来的?它的最初来源是这个仓库:github.com/zed-industries/extensions

The list of repositories in the zed-industries/extensions repository
zed-industries/extensions 仓库中的仓库列表

这个仓库是 Zed 中存在哪些扩展的真相来源。它包含了对所有其他 Zed 扩展仓库的引用。

(你是不是有种本能的反应,心想“哎呀!扩展注册表竟然是一个仓库?”嘿,我们理解你!当我问 Max 和 Marshall 他们认为未来扩展系统需要做出哪些改变时,Max 说这个仓库很可能需要移除。它无法扩展,但到目前为止运行得非常好。)

这个仓库及其包含的扩展列表会定期镜像到 zed.dev,我们的 Zed API 就运行在此之上。我在这里宽松地使用了“镜像”一词:并非实际的 git 仓库内容被镜像,而是其内容(你将看到)。当你运行 zed: extensions 时,你的 Zed 会向 zed.dev 的 API 发送请求,并请求扩展列表。

那么,当你决定通过点击 Install 按钮来安装一个扩展时,会发生什么呢?

首先,它当然需要下载。但问题是:*什么*正在被下载?是*Rust 代码*吗?你下载并编译 Rust 代码吗?

结果发现,不,你没有。这就是事情变得有趣的地方。

extensions 仓库到你的 Zed

extensions 仓库包含一个 CI 步骤,最终执行“扩展 CLI”,这是一个小型 CLI 程序,位于 Zed 仓库的 extensions_cli crate 中

它由单个文件组成,其主要作用是接收包含 Zed 扩展的目录并进行编译。

对我们这些探索者来说幸运的是,我们不需要 CI 系统来完成这项工作。我们可以在自己的机器上手动运行二进制文件。以下是我使用它(这里二进制文件名为 zed-extension)编译 metals-zed 时的情况

$ mkdir output
$ zed-extension --source-dir ./metals-zed --output ./output --scratch-dir $(mktemp -d)
info: downloading component 'rust-std' for 'wasm32-wasip1'
info: installing component 'rust-std' for 'wasm32-wasip1'
 
$ ls -1 ./output
archive.tar.gz
manifest.json

这产生了两个文件:archive.tar.gzmanifest.json

manifest.json 包含你在 Zed 内部的 zed: extension 视图中看到的元数据

$ cat ./output/manifest.json | jq .
{
  "name": "Scala",
  "version": "0.1.0",
  "description": "Scala support.",
  "authors": [
    "Igal Tabachnik <[email protected]>",
    "Jamie Thompson <[email protected]>",
 
  ],
  "repository": "https://github.com/scalameta/metals-zed",
  "schema_version": 1,
  "wasm_api_version": "0.0.6"
}

它是由仓库中的 extension.toml 文件生成的。

那么 archive.tar.gz 中有什么呢?

$ tar -ztf ./output/archive.tar.gz
./
./extension.toml
./extension.wasm
./languages/
./grammars/
./grammars/scala.wasm
./languages/scala/
./languages/scala/outline.scm
./languages/scala/indents.scm
./languages/scala/highlights.scm
./languages/scala/config.toml
./languages/scala/overrides.scm
./languages/scala/injections.scm
./languages/scala/runnables.scm
./languages/scala/brackets.scm

有一个 extension.toml 文件,它与 扩展仓库中的文件*几乎*相同,但包含一些在编译时添加的更多元数据,包括*该扩展所针对的 Zed 扩展 Rust API 版本*——请记住这一点,我们稍后会回来讨论。

extension.wasm 文件是我们之前看到的 lib.rs 文件,即 Rust 代码,编译成 Wasm。

grammars/scala.wasm 是编译成 Wasm 的 Tree-sitter 语法。(Max 的博客文章解释了 Tree-sitter 和 Wasm 是如何在这里编译的。)

然后还有一堆 Scheme 文件——outline.scmhighlights.scm……——其中包含 Tree-sitter 查询,Zed 在运行时执行这些查询,例如,为 Scala 文件获取语法高亮。

所以,到目前为止我们知道的是:扩展是通过一个小型 CLI 工具编译的,编译结果是两个文件。一个包含元数据的 manifest.json 和一个包含两个 Wasm 文件以及一堆小型 Scheme 文件的归档。

在 CI 中,编译扩展之后会发生下一件事,即这两个文件被上传到一个可以通过 zed.dev API 访问的地方。相关代码位于 zed-industries/extensions 仓库中,以一些编写整洁的 JavaScript 代码形式存在于 package-extensions.js 文件中。

该代码会遍历仓库中的所有扩展,使用 Zed 仓库中的 extensions_cli 对它们进行编译(就像我向你展示的那样),然后将生成的 archive.tar.gzmanifest.json 文件上传到 S3 存储桶。

而*那个*存储桶——而不是 zed-industries/extensions 仓库——由 zed.dev 镜像并通过其 API 访问。每隔几分钟,zed.dev 就会从 S3 存储桶中获取 manifest.json 文件,并将其内容存储在数据库中,以及 archive.tar.gz 文件的 URL。

我们得到了第一个答案:当你安装一个扩展时,你不会编译 Rust 代码。你下载并解压一个包含 Wasm 和 Scheme 代码的归档文件。

但这跳过了很多内容。我的意思是,当扩展从 Rust 编译到 Wasm 时,究竟会发生什么?

编译扩展

让我们来看看一个用 Rust 编写的非常简单的 Zed 扩展

use zed_extension_api::{self as zed, Result};
 
struct MyExtension;
 
impl MyExtension {
    const SERVER_BINARY_NAME: &'static str = "my-language-server";
}
 
impl zed::Extension for MyExtension {
    fn new() -> Self {
        Self
    }
 
    fn language_server_command(
        &mut self,
        _language_server_id: &zed::LanguageServerId,
        worktree: &zed::Worktree,
    ) -> Result<zed::Command> {
        let path = worktree
            .which(Self::SERVER_BINARY_NAME)
            .ok_or_else(|| format!("Could not find {} binary", Self::SERVER_BINARY_NAME))?;
 
        Ok(zed::Command {
            command: path,
            args: vec!["--use-printf-debugging".to_string()],
            env: worktree.shell_env(),
        })
    }
}
 
zed::register_extension!(MyExtension);

这是一个我刚刚拼凑的虚构扩展。它所做的只是定义如何运行同样虚构的 my-language-server 二进制文件:它在 Zed 打开的工作区 $PATH 中查找二进制文件的位置,并返回一些用于运行它的参数。

但是 MyExtension 是一个空结构体。它甚至没有任何字段。它之所以是一个 Zed 扩展,是因为它实现了 zed::Extension trait——它来自哪里?

它是我的虚构扩展的 Cargo.toml 中添加的一个依赖项,在最后一行

[package]
name = "test-extension"
version = "0.1.0"
edition = "2021"
 
[lib]
crate-type = ["cdylib"]
 
[dependencies]
zed_extension_api = "0.0.6"

那么下一个问题是:那个 zed_extension_api crate 里有什么?它不可能只是 Rust 代码,对吧?毕竟,我们希望扩展能够编译成 Wasm 并作为 Wasm 运行。

这就是事情变得*真正*有趣的地方!

extension_api crate

zed::Extension trait 在 Zed 仓库的 extension_api crate 中定义,具体来说是 extension_api.rs 文件,它乍一看——非常正常

// Excerpt from `extension_api.rs`
 
/// A Zed extension.
pub trait Extension: Send + Sync {
    /// Returns a new instance of the extension.
    fn new() -> Self
    where
        Self: Sized;
 
    /// Returns the command used to start the language server for the specified
    /// language.
    fn language_server_command(
        &mut self,
        _language_server_id: &LanguageServerId,
        _worktree: &Worktree,
    ) -> Result<Command> {
        Err("`language_server_command` not implemented".to_string())
    }
 
    /// Returns the initialization options to pass to the specified language server.
    fn language_server_initialization_options(
        &mut self,
        _language_server_id: &LanguageServerId,
        _worktree: &Worktree,
    ) -> Result<Option<serde_json::Value>> {
        Ok(None)
    }
 
    // [...]
}

这是一个普通的 Rust trait,定义了一堆带有默认实现的方法,扩展可以选择实现这些方法。它是手写的(你很快就会发现我为什么要这样区分),并定义了我们扩展 API 的最外层。从扩展作者的角度来看,这个 trait 是他们需要交互的全部内容。

在同一个文件中,还有更多的类型定义,我们在上面的虚构扩展中也看到了这些定义。例如 LanguageServerId。它的定义 在这里,像这样

/// The ID of a language server.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct LanguageServerId(String);

再次:看起来正常的 Rust。

但是,如果你查看那个文件并尝试跳转到某些定义,你会发现有些定义不是手写的——它们甚至没有出现在文件中。例如,Worktree,我们也在上面虚构的代码中看到了它。它在哪里定义的?

这就是 Rust 遇到 Wasm 的地方。或者 Wasm 遇到 Rust 的地方。或者 Wasm 天空遇到 Rust 大海。或者 Rust 风格遇到 Wasm 实质——你懂的,我们快要弄清楚了。

因为这些类型是在*编译时*定义的!没错。这些类型源自同一个 extension_api.rs 文件中的 这一小段,在编译时

// Excerpt from extension_api.rs
 
mod wit {
    wit_bindgen::generate!({
        skip: ["init-extension"],
        path: "./wit/since_v0.2.0",
    });
}

那么这段话是做什么的呢?要回答这个问题,我们必须退一步。

Marshall 和 Max 向我解释说,扩展是基于 WebAssembly 组件模型构建的,我以前从未听说过,但后来研究过。这里有很多可以谈论的,但为了保持重点,我将跳过一些细节,只提对我们的探索至关重要的内容:WebAssembly 组件模型允许我们定义 Wasm 模块之间以及 Wasm 模块与其执行宿主之间的接口——API。它允许我们定义可以在 Wasm 模块及其宿主之间共享的类型。

用实际的话说:如果没有 Wasm 组件模型,如果你想与 Wasm 模块交互——例如,通过在 Wasm 宿主中运行它并向其传递数据,或者调用其中的函数并取出数据——所有跨越 Wasm 模块边界的数据都必须基本表示为整数或浮点数。(如果你想让 Wasm 模块与你自己的工具之外的更多工具交互,惯例是使用 C ABI。)

整数和浮点数很酷,别误会我的意思,但字符串和结构体也很酷。Wasm 组件模型允许我们将字符串、结构体、数组——所有这些花哨的东西——传递给 Wasm 模块并取回它们。它允许我们传递像 struct Animal { name: String, age: u8 } 或上面提到的 Worktree 到 Wasm 模块。

它要求您想与 Wasm 模块交换的类型必须预先定义,使用一种 IDL(接口定义语言),称为 WIT,即 Wasm Interface Type 的缩写

因此,再次向前一步,看看上面的代码,让我们看看这 4 行代码做了什么

wit_bindgen::generate!({
    skip: ["init-extension"],
    path: "./wit/since_v0.2.0",
});

generate! 宏来自 wit_bindgen crate。它接收 ./wit/since_v0.2.0 目录中的文件(其中包含 WIT 定义),并将它们转换为 Rust 中与 Wasm 兼容的类型。

以下是 ./wit/since_v0.2.0/extension.wit 文件的摘录

package zed:extension;
 
world extension {
    /// A command.
    record command {
        /// The command to execute.
        command: string,
        /// The arguments to pass to the command.
        args: list<string>,
        /// The environment variables to set for the command.
        env: env-vars,
    }
 
    /// A Zed worktree.
    resource worktree {
        /// Returns the ID of the worktree.
        id: func() -> u64;
        /// Returns the root path of the worktree.
        root-path: func() -> string;
        /// Returns the textual contents of the specified file in the worktree.
        read-text-file: func(path: string) -> result<string, string>;
        /// Returns the path to the given binary name, if one is present on the `$PATH`.
        which: func(binary-name: string) -> option<string>;
        /// Returns the current shell environment.
        shell-env: func() -> env-vars;
    }
 
    /// Returns the command used to start up the language server.
    export language-server-command: func(language-server-id: string, worktree: borrow<worktree>) -> result<command, string>;
 
    /// [... other definitions ...]
}

一个 command 记录,一个 worktree 资源,以及一个 language-server-command 函数。language-server-command——听起来很熟悉,对吧?那是因为我上面展示的虚构扩展包含了这个方法的实现,它是 zed::Extension API 的一部分

fn language_server_command(
    &mut self,
    language_server_id: &LanguageServerId,
    worktree: &Worktree,
) -> Result<Command> {
    // ...
}

那么,这又是如何结合在一起的呢——*.wit 文件中的定义和 Rust 代码?

为了回答这个问题,我们先忽略 &WorktreeResult<Command> 类型,专注于 &LanguageServerId,它更容易理解,因为它正如我们上面看到的,只是一个 newtype 包装了一个 String

pub struct LanguageServerId(String);

在我刚才给你们看的那段代码中,在 ./wit/since_v0.2.0/extension.wit 文件中,language-server-id 也只是一个 string

export language-server-command: func(language-server-id: string, worktree: borrow<worktree>) -> result<command, string>;

这意味着我们在 Rust 端有一个 String(封装在另一个 在运行时添加的类型中),在 WIT 文件中有一个 string

但是 Wasm 模块不了解字符串!它们只了解数字和指向数字的指针(从技术上讲,它们也是数字——别给我发愤怒的信件)。

为了弥合这一差距,wit_bindgen::generate! 将 WIT 定义转换为 Rust 类型定义,这些定义一方面是有效的 Rust 代码(因此我们可以在编写扩展时使用它们),另一方面是 Wasm 兼容的、C-ABI 导出的代码。

我们可以使用 cargo expand 来查看 wit_bindgen::generate! 宏在编译时是如何做到这一点的。以下是 wit_bindgenlanguage-server-command 函数生成的内容

#[export_name = "language-server-command"]
unsafe extern "C" fn export_language_server_command(
    arg0: *mut u8,
    arg1: usize,
    arg2: i32,
) -> *mut u8 {
    self::_export_language_server_command_cabi::<Component>(arg0, arg1, arg2)
}
 
unsafe fn _export_language_server_command_cabi<T: Guest>(
    arg0: *mut u8,
    arg1: usize,
    arg2: i32,
) -> *mut u8 {
    let handle1;
    let len0 = arg1;
    let bytes0 = _rt::Vec::from_raw_parts(arg0.cast(), len0, len0);
    let result2 = T::language_server_command(
        _rt::string_lift(bytes0),
        {
            handle1 = Worktree::from_handle(arg2 as u32);
            &handle1
        },
    );
 
    // [... a lot more code that only deals with numbers ...]
}

理解这一点的最佳方法是从内到外阅读:就在 _export_language_server_command_cabi 的中间,你可以看到它调用了 T::language_server_command——这就是对我们扩展的 Rust 代码的调用!

但是由于 Wasm 模块必须公开一个 C ABI 兼容的函数——也就是 extern "C" fn export_language_server_command ——它不能使用 String&Workspace。C 和 Wasm 模块不了解这些类型。

解决方案是将我们拥有的类型——String&Worktree——转换为 Wasm 模块可以理解的 C ABI 兼容类型。String 被转换为两个参数:指向字符串数据的 arg0: *mut u8,和字符串长度的 arg1borrow<worktree>,一个指向 Worktree 的引用,是一个 i32——一个指针。

换句话说:wit_bindgen::generate! 根据 WIT 定义,围绕我们的 Rust 代码生成 C ABI 和 Wasm 模块兼容的包装代码。这会在编译时针对 WIT 文件中的所有类型和函数发生。然后,连同扩展代码一起,全部编译成 Wasm。

此时,让我们暂停一下,承认这有点让人费解。也许不止一点。

但是,撇开不同的工具、宏和 ABI 不谈,简短的版本是这样的

  • Zed 希望执行 Zed 扩展,这些扩展是 Wasm 模块,并实现了在 extension_api crate 中用 Rust 定义的 zed::Extension trait。
  • 用 Rust 编写的 Zed 扩展可以实现 zed::Extension trait。
  • 为了将实现此 trait 的扩展转换为符合预定义接口的 Wasm 模块,我们使用 WIT 和 wit_bindgen! 生成 Rust 代码,这些代码在与扩展的其余部分一起编译为 Wasm 时,会公开一个与 Wasm 模块兼容的 API,该 API 调用我们一次性编写的 Rust 扩展代码。

更简洁地说:扩展是用 Rust 编写的,针对预定义的接口,并编译成 Wasm 模块,然后可以在 Zed 中执行。

那么,这一部分是如何工作的,即在 Zed 中执行?

在 Zed 中运行 Wasm

到目前为止,Zed 扩展的生命周期是这样的

我们定义了一个扩展 API,我们编写了一个实现该 API 的扩展,我们将其编译成 Wasm,我们上传了该 Wasm 模块,我们点击了“安装扩展”按钮,我们下载了包含 Wasm 代码的存档——接下来是什么?

首先,下载的扩展 存档被解压,其中包含的文件被放置到正确的位置,并且扩展被添加到内部索引中。然后,这个名为 extensions_updated 的方法会遍历所有扩展,删除未安装的扩展,清理它们,重新加载已安装的扩展,并添加新添加的扩展。

extensions_updated 的末尾,有这段代码,它加载每个扩展的 Wasm 二进制代码

// Extract from extension_store.rs
 
let mut path = root_dir.clone();
path.extend([extension.manifest.clone().id.as_ref(), "extension.wasm"]);
let mut wasm_file = fs
    .open_sync(&path)
    .await
    .context("failed to open wasm file")?;
 
let mut wasm_bytes = Vec::new();
wasm_file
    .read_to_end(&mut wasm_bytes)
    .context("failed to read wasm")?;
 
wasm_host
    .load_extension(
        wasm_bytes,
        extension.manifest.clone().clone(),
        cx.background_executor().clone(),
    )
    .await
    .with_context(|| {
        format!("failed to load wasm extension {}", extension.manifest.id)
    })

神奇之处在于最后一句 wasm_host.load_extension 调用。该调用将 Wasm 模块加载到我们在 Zed 中使用的 Wasm 运行时:Wasmtime

这是代码

// Excerpt from `wasm_host.rs`
 
pub fn load_extension(
    self: &Arc<Self>,
    wasm_bytes: Vec<u8>,
    manifest: Arc<ExtensionManifest>,
    executor: BackgroundExecutor,
) -> Task<Result<WasmExtension>> {
    let this = self.clone();
    executor.clone().spawn(async move {
        let zed_api_version = parse_wasm_extension_version(&manifest.id, &wasm_bytes)?;
 
        let component = Component::from_binary(&this.engine, &wasm_bytes)
            .context("failed to compile wasm component")?;
 
        let mut store = wasmtime::Store::new(
            &this.engine,
            WasmState {
                ctx: this.build_wasi_ctx(&manifest).await?,
                manifest: manifest.clone(),
                table: ResourceTable::new(),
                host: this.clone(),
            },
        );
 
        let mut extension = Extension::instantiate_async(
            &mut store,
            this.release_channel,
            zed_api_version,
            &component,
        )
        .await?;
 
        extension
            .call_init_extension(&mut store)
            .await
            .context("failed to initialize wasm extension")?;
 
        let (tx, mut rx) = mpsc::unbounded::<ExtensionCall>();
        executor
            .spawn(async move {
                while let Some(call) = rx.next().await {
                    (call)(&mut extension, &mut store).await;
                }
            })
            .detach();
 
        Ok(WasmExtension {
            manifest,
            tx,
            zed_api_version,
        })
    })
}

它从 *.wasm 文件中的二进制数据创建一个 wasmtime::Component,初始化一个 wasmtime::Store,并通过 Extension::instantiate_async 调用实例化我们的 Extension 类型。

最后一部分,Extension 类型的实例化,就是扩展在 Zed 中变得活跃的地方。

如果我们深入代码,尝试了解 instantiate_async 内部发生了什么,我们会首先看到这个

// Excerpt from wasm_host/wit.rs
 
impl Extension {
    pub async fn instantiate_async(
        store: &mut Store<WasmState>,
        release_channel: ReleaseChannel,
        version: SemanticVersion,
        component: &Component,
    ) -> Result<Self> {
        // Note: The release channel can be used to stage a new version of the extension API.
        let allow_latest_version = match release_channel {
            ReleaseChannel::Dev | ReleaseChannel::Nightly => true,
            ReleaseChannel::Stable | ReleaseChannel::Preview => false,
        };
 
        if allow_latest_version && version >= latest::MIN_VERSION {
            let extension =
                latest::Extension::instantiate_async(store, component, latest::linker())
                    .await
                    .context("failed to instantiate wasm extension")?;
            Ok(Self::V020(extension))
        } else {
            // [... code that handles other versions ...]
        }
    }
}

它根据编译所用的扩展 API 来实例化扩展。我之前告诉过你,要记住扩展 API 版本存储在展开的 extension.toml 中——这里它又回来了。

但如果我们接着尝试跳转到 latest::Extension::instantiate_async 的定义,它就在中间被调用,看看它做了什么,我们会发现走不远,因为——惊喜!——那段代码也是在编译时生成的!而且它也来自 WIT 文件——实际上就是我们之前看到的那些 WIT 文件。

如果我们打开 crates/extension/src/wasm_host/wit/since_v0_2_0.rs(文件路径中的 wasm_host 告诉我们我们现在处于扩展/宿主分隔的另一侧),我们会看到这个宏调用

wasmtime::component::bindgen!({
    async: true,
    trappable_imports: true,
    path: "../extension_api/wit/since_v0.2.0",
    with: {
         "worktree": ExtensionWorktree,
         "key-value-store": ExtensionKeyValueStore,
         "zed:extension/http-client/http-response-stream": ExtensionHttpResponseStream
    },
});

就像我们在扩展端使用 wit_bindgen::generate! 创建 Wasm 模块兼容的 Rust 绑定一样,这次对 wasmtime::component::bindgen! 的调用现在创建了宿主端代码。它使用了我们之前看到的相同 WIT 文件,这次传入资源以将 WIT 类型映射到 Zed 应用程序代码类型。

我不会在这里深入所有细节。只需知道这会在 Wasmtime 中实例化扩展 Wasm 模块并使 API 的宿主端可用就足够了。

(如果你想了解更多,我强烈建议你阅读 Wasm 组件模型、wit_bindgenwasmtime 的文档——有一些很好的示例可以尝试。)

不过,我想提一件事,因为它让我大吃一惊。看到那个 wasmtime::component::bindgen! 调用中的 async: true 参数了吗?这个参数确保宿主调用扩展时的方法是*异步*的(即:异步 Rust),即使从扩展内部看它们是非异步函数!这意味着如果扩展通过下载语言服务器等方式进行阻塞 I/O,那么从宿主角度看它是异步代码,宿主可以在扩展等待下载时并发地执行其他任务。

现在,回到我们之前的地方。一旦 latest::Extension::instantiate_async 调用返回且扩展初始化完成——那么实际上就是时候在 Zed 中开始使用它了。

例如,一旦扩展初始化完成,我们上面虚构的扩展中的 language_server_command 方法如何在 Zed 应用程序级代码中被调用

// extension_lsp_adapter.rs, simplified
 
struct ExtensionLspAdapter {
    extension: WasmExtension,
    language_server_id: LanguageServerName,
    config: LanguageServerConfig,
    host: Arc<WasmHost>,
}
 
#[async_trait(?Send)]
impl LspAdapter for ExtensionLspAdapter {
    fn get_language_server_command<'a>(
        self: Arc<Self>,
        delegate: Arc<dyn LspAdapterDelegate>,
        _: LanguageServerBinaryOptions,
        _: futures::lock::MutexGuard<'a, Option<LanguageServerBinary>>,
        _: &'a mut AsyncAppContext,
    ) -> Pin<Box<dyn 'a + Future<Output = Result<LanguageServerBinary>>>> {
        async move {
            let command = self
                .extension
                .call({
                    let this = self.clone();
                    |extension, store| {
                        async move {
                            let resource = store.data_mut().table().push(delegate)?;
                            let command = extension
                                .call_language_server_command(
                                    store,
                                    &this.language_server_id,
                                    &this.config,
                                    resource,
                                )
                                .await?
                                .map_err(|e| anyhow!("{}", e))?;
                            anyhow::Ok(command)
                        }
                        .boxed()
                    }
                })
                .await?;
 
            let path = self
                .host
                .path_from_extension(&self.extension.manifest.id, command.command.as_ref());
 
            // ...
 
            Ok(LanguageServerBinary {
                path,
                arguments: command.args.into_iter().map(|arg| arg.into()).collect(),
                env: Some(command.env.into_iter().collect()),
            })
        }
        .boxed_local()
    }
// [...]
}

你必须稍微眯眼才能不被这里大量的语法和类型系统内容分散注意力,但如果你专注于中间缩进的代码,你会看到这个调用:extension.call_language_server_command()

这反过来又会调用这个

pub async fn call_language_server_command(
    &self,
    store: &mut Store<WasmState>,
    language_server_id: &LanguageServerName,
    config: &LanguageServerConfig,
    resource: Resource<Arc<dyn LspAdapterDelegate>>,
) -> Result<Result<Command, String>> {
    match self {
        Extension::V020(ext) => {
            ext.call_language_server_command(store, &language_server_id.0, resource)
                .await
        }
        // [...]
    }
}

认出来了吗?这就是我们上面看到的 extern "C" fn export_language_server_command 的宿主侧!

它传入 LanguageServerName——那个 String!——以及 resource,它在 WIT 文件和扩展侧被称为 worktree

由于宿主端的 bindgen! 调用,这个调用最终会将我们这里的 Rust 类型转换为 C ABI 兼容类型,然后使用 Wasm 引擎调用在扩展端生成的 export_language_server_command 函数。

……就这样!这就是一个扩展的生命。从 Rust 代码,到 Wasm,封装成一个档案,上传到云端,再下载到你的 Zed,进入 Wasm 引擎,然后由 Zed 应用程序代码调用。

头晕了吗?

天时地利

当我和 Marshall 以及 Max 一起浏览代码时,我确实感到头晕目眩。其中涉及大量的间接引用、大量的不同库、工具和 API。

但它奏效了,坦白说,这让我很惊讶。我以前也用过 Wasm,简直不敢相信这里所有的组件——WIT、Wasmtime、wit_bindgen、Rust——都能完美契合。过去每次我尝试用 Wasm 做点什么时,都会遇到被废弃的库,或者这个引擎遵循那个标准,但那个语言的工具链还没跟上,或者标准已经被放弃了。

这一次,对于 Zed 来说,情况不同了。在我们的谈话中,我特意问 Max 和 Marshall,所有这些库和工具是如何完美结合在一起的,这简直是个奇迹。Marshall 这样说(为了清晰起见,经过编辑和意译):

当我们最初尝试扩展 API 时,我们最初是朝着 JavaScript 方向发展的。我们打算嵌入一个 JavaScript 运行时,研究像 Deno 的 V8 绑定这样的东西。

但当 Max 和我研究它时,我们意识到我们并不真的想写 JavaScript。我们想用 Rust 来编写我们的扩展。

但当我们最初考虑将 WebAssembly 用于我们的扩展时,它还不够成熟。所以这项努力就胎死腹中了。

然后,今年年初,Max 说,嘿,我们再看看这个。我们发现了 WebAssembly 组件模型,而所有这些东西直到最近几个月才变得可用。

我们正处在正确的时间,正确的地点。我们构建了一个概念验证。我们把 wasmtime 和 Wasm 组件模型都连接起来了。然后一切就在那个时候水到渠成。

天时地利,造就了一些无可否认的强大功能。

致敬黑客们

我想以 Marshall 在我们谈话结束时告诉我们的故事来结束本期《Zed 解密》,并向一位真正的黑客致敬。

目前,我们的扩展 API 只允许用户添加新的语言支持、主题、代码片段和斜杠命令。它不支持修改 UI 来创建新的面板,也不支持随意发起 HTTP 请求,或者随意触碰文件系统。

它受到限制,是的,我们知道人们想要一个更强大的 API(不,真的,我们知道,你不需要告诉我们),但我们还没有做到这一点,因为有大量的工作要做。(当你们中的一些人要求一个更强大的扩展 API 时,其他人却在问:“Linux 什么时候?”)

然而,事实证明,如果你足够努力,你*确实*可以利用 API 做更多事情。

Jozef Steinhübl,一位 15 岁的黑客,在 GitHub 上以 xhyrom 的名字为人所知,他构建了一个 Zed Discord 状态扩展。不,我们没有在扩展 API 中提供任何 Discord 功能。Jozef 仍然使其工作,通过——请听好——构建一个语言服务器,该服务器与 Zed 通信以了解用户打开了哪些文件,然后将其广播到 Discord。

一个人看到限制,另一个人却看到穿针引线的机会,得以实现他们的想法。太美妙了。


正在寻找更好的编辑器吗?

您今天就可以在 macOS、Windows 或 Linux 上试用 Zed。立即下载


我们正在招聘!

如果您对我们博客中涵盖的主题充满热情,请考虑加入我们的团队,帮助我们实现软件开发的未来。