← 返回博客

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


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

扩展系统由 MaxMarshall 构建,并且在四月份,Max 撰写了一篇关于扩展背后一些复杂工程挑战以及他们如何克服这些挑战的优秀博客文章。它还解释了扩展程序如何作为 WebAssembly (Wasm) 模块运行,以及为什么和如何在 Wasm 中使用 Tree-sitter。

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

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

配套视频

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

这篇文章附带一个 1 小时的配套视频,其中 Marshall、Max 和 Thorsten 探索 Zed 中的扩展,深入研究代码库以了解 Rust、Wasm、WIT 和扩展如何组合在一起并在 Zed 中运行。

在此观看视频 →

问题

我用作示例来询问“安装这个会发生什么?”的扩展是 zed-metals 扩展。 它通过添加 Scala Tree-sitter 解析器、Tree-sitter 查询 来为 Zed 添加 Scala 支持, 并且 - 在那次对话中,这对我来说很有趣 - 它还添加了对 Metals 语言服务器 的支持。

而且它是用 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 步骤,该步骤最终会执行“extensions CLI”,这是一个小型 CLI 程序,位于 Zed 仓库的 extensions_cli crate 中。

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

幸运的是,我们这些探索者不需要使用 CI 系统来实现这一点。我们可以手动在我们的机器上运行该二进制文件。这是我使用它编译 metals-zed 时的样子(这里的二进制文件称为 zed-extension

$ 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 文件,即被编译为 Wasm 的 Rust 代码。

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 仓库中,作为 package-extensions.js 中编写整齐的 JavaScript 代码。

该代码遍历仓库中的所有扩展程序,使用 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 Component Model 构建的,我以前从未听说过,但之后查阅了相关资料。 有很多我们可以讨论的,但为了保持专注,我将跳过很多细节,只提及对我们的探索至关重要的内容:WebAssembly Component Model 允许我们定义接口——API——在 Wasm 模块之间,以及在 Wasm 模块和执行它们的宿主之间。 它允许我们定义可以在 Wasm 模块及其宿主之间共享的类型。

用通俗的话来说:如果没有 Wasm Component Model,如果你想与 Wasm 模块交互——比如通过在 Wasm 宿主中运行它并将数据传递给它,或者调用其中的函数并取出数据——所有跨越 Wasm 模块边界的数据基本上必须表示为整数或浮点数。(如果你想让 Wasm 模块与你的工具之外的更多工具交互,通常使用 C ABI。)

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

它要求你要与 Wasm 模块交换的类型必须提前定义,使用一种名为 WIT,即 Wasm Interface Type 的 IDL(接口定义语言)。

因此,再次向前迈一步,看看上面的代码,让我们看看这 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,它更容易理解,因为它,正如我们上面看到的,只是一个围绕 Stringnewtype

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,指向字符串的数据,以及 arg1,字符串的长度。borrow<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),即使它们看起来像扩展内部的非异步函数! 这意味着,例如,如果扩展通过下载语言服务器来执行阻塞 IO,那么从主机的角度来看,它是异步代码,并且主机可以在扩展等待下载时并发地执行其他操作。

现在,回到我们之前的位置。 一旦 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 Component Model 以及所有这些东西直到最近几个月才变得可用。

我们在正确的时间,出现在了正确的地点。我们构建了一个概念验证。我们把 wasmtime 和 Wasm 组件模型连接起来。从那时起,一切都开始变得顺利了。

天时地利,从而产生了一些不可否认的强大功能。

向黑客致敬

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

目前,我们的扩展 API 只允许用户添加新的语言支持、主题、代码片段和斜杠命令。它不支持修改 UI 以创建新的面板,或者发出任意 HTTP 请求,或者以您想要的方式访问文件系统。

它是有限的,是的,我们知道人们想要一个更强大的 API(真的,我们知道,您不必告诉我们),但我们还没有抽出时间来做这件事,因为有很多事情要做。(当你们中的一些人要求一个更强大的扩展 API 时,另一些人则问:“Linux 何时上线?”)

然而,事实证明,如果您足够努力,您可以用 API 做更多的事情。

Jozef Steinhübl,一位 15 岁的黑客,在 GitHub 上的名字是 xhyrom,构建了一个 Zed Discord 状态扩展。不,我们在扩展 API 中不提供任何 Discord 功能。Jozef 仍然通过以下方式使其工作——请注意——构建一个语言服务器,该服务器与 Zed 通信以确定用户打开了哪些文件,然后将这些信息广播到 Discord。

当一个人看到局限性时,另一个人会看到针眼,他们可以通过它穿入他们的想法。太棒了。