← 返回博客

使用 Tree-Sitter 的语法感知任务生成


您是否曾经想在 Zed 内部执行代码?运行测试、或者一个 linter、或者编译器、或者一个脚本,或者一个 shell 单行命令?

观看

您刚才看到的是我使用 Zed *Tasks* 从 Zed 内部执行 Go 测试,并将当前函数的名称传递给 go test 命令。

作为一个新功能,Tasks 最早于二月份在 Zed 中发布,版本为 v0.124.7

但从那时起,Piotr、Kirill 和 Mikayla 一直在不断改进它们。现在,在最新的 Zed Preview 版本 v0.136 中,它们坦率地说令人印象深刻。

它们简单到了极致,同时又功能强大。它们还使用了一些非常棒的 Tree-sitter 技术,这就是我想深入研究它们的原因。

配套视频

使用 Tree-Sitter 的语法感知任务生成

这篇文章附带了一个 1 小时的配套视频,Thorsten 在视频中与 Piotr 和 Kirill 进行了交谈,他们(与 Mikayla 一起)构建了 Tasks。他们一起探索了运行 Tasks 的所有不同方式,然后深入研究了它们的实现。

在此处观看视频 →

运行任务

首先,如何运行任务?打开 Zed,按下 cmd-shift-p 打开命令面板,然后输入 task: spawn

您会看到另一个模态框,您可以在其中输入要执行的命令。opt-return 启动任务。像这样

在 Zed Preview 0.136 中运行 go run . 作为任务

现在,再次按下 cmd-shift-p,但这次输入 task: rerun

重新运行相同的任务

顾名思义,这将重新运行您上次执行的任务。如果您运行了多个不同的任务,它总是重新运行最后一个任务。(除了使用 opt-return 启动任务外,您还可以使用 cmd-opt-return,这将导致该任务作为_临时_任务运行 —— 一个不会被标记为“上次运行的任务”的任务。)

如果您认为这太多的输入,并且“哦,我的可怜的手”:有快捷键可以生成和重新运行任务 - opt-shift-t 绑定到 task: spawn,而 opt-t 绑定到 task: rerun

好的,到目前为止,一切都很好。每个曾经想用快捷键执行代码的人都往后一靠,叹了口气“终于”。

还有更多。

任务变量

在上面的小介绍视频中,您看到我使用 $ZED_SYMBOL 来引用运行任务时光标所在的函数。

$ZED_SYMBOL 由 Tree-sitter 提供支持,并填充为包含当前光标位置的最后一个符号的名称。这应该与您可以在 Zed 中窗格顶部的面包屑中看到的最后一个符号相对应。

还有更多这样的变量可用

  • $ZED_FILE 引用当前打开文件的绝对路径
  • $ZED_ROW$ZED_COLUMN 包含光标的行/列
  • $ZED_WORKTREE_ROOT 是 Zed 中工作树根文件夹的绝对路径

您可以在此处找到完整和最新的变量列表,但我想在此处强调其中一个:$ZED_SELECTED_TEXT

使用任务评估代码

$ZED_SELECTED_TEXT 包含 —— 是的,您猜对了 —— 当前选定的文本。这听起来可能没什么,但它_强大_。

看看

通过在 psql 中执行 SQL 语句来使用 Tasks 评估它们

在这个视频中,我生成了一个任务,该任务将 $ZED_SELECTED_TEXT 传递给 PostgreSQL CLI 工具 psql

我选择第一个 SQL 语句,生成任务,然后选择下一个语句,重新运行任务,选择下一个语句,重新运行任务,选择最后一个语句,重新运行任务。

如果您注意了或者已经尝试过任务,您可能会想:等等,您是如何重新运行任务的,以便 $ZED_SELECTED_TEXT 始终包含最新的选择,而不是您运行任务的第一个选择?

答案在于我添加到我的个人 Zed keymap.json 中的这个快捷键

[
  {
    "context": "EmptyPane || SharedScreen || vim_operator == none && !VimWaiting && vim_mode != insert",
    "bindings": {
      ", r e": ["task::Rerun", { "reevaluate_context": true }]
    }
  }
]

忽略"context"Vim模式相关的部分,重要的是这个:{ "reevaluate_context": true }

reevaluate_context设置为true后,我可以随时重新运行上次的任务,并且变量—$ZED_SELECTED_TEXT,或者$ZED_FILE,…—会被重新计算。

还有更多用于task::Rerun的变量allow_concurrent_runsuse_new_terminal。在配套视频中,你可以观看Piotr和Kirill如何向我解释这些变量以及如何最好地使用它们。

我告诉你,这是些强大的东西:通过任务,你可以计算完整的文件、脚本行、选择的内容,等等… 天空才是极限!或者说,你能在你的shell中运行的东西才是极限。

但也许你又在想“哦,我可怜的手啊”,因为你看到我输入这些命令和变量,并认为肯定有更好的方法? 确实有。

定义任务

你可以使用*任务模板*来定义任务。这些是JSON文件,你可以在其中定义多个不同的任务并使用任务变量。

任务模板可以放在两个不同的地方

  • 在你的项目的根文件夹中的.zed/tasks.json文件中(使用zed: open local tasks来创建/打开该文件)
  • 在全局~/.config/zed/tasks.json文件中(使用zed: open tasks来创建/打开该文件)

这是一个这样的文件的示例

[
  {
    "label": "My cool loop",
    "command": "for i in {1..5}; do echo \"Hello $ZED_FILE $ZED_ROW - $i/5\"; sleep 1; done"
  },
  {
    "label": "ruby eval: '$ZED_SELECTED_TEXT'",
    "command": "ruby -e '$ZED_SELECTED_TEXT'",
    "use_new_terminal": false
  },
  {
    "label": "go test - current function",
    "command": "go test . -run $ZED_SYMBOL",
    "reveal": "always"
  },
  {
    "label": "Number of dotfiles",
    "command": "find . -name '.*' -depth 1 | wc -l",
    "cwd": "/Users/thorstenball"
  }
]

将此文件放在.zed/tasks.json中,当我运行task: spawn时,我得到以下模态框

Note how the "ruby eval" task doesn't show up. That's because I don't have text selected.
请注意,“ruby eval”任务没有显示。那是因为我没有选择任何文本。

如果你像这样在tasks.json文件中定义任务,并带有label,那么你还可以创建键绑定来生成特定的任务。例如

{
  "context": "EmptyPane || SharedScreen || vim_operator == none && !VimWaiting && vim_mode != insert",
  "bindings": {
    ", r t": ["task::Spawn", { "task_name": "My cool loop" }]
  }
}

这将从上面生成My cool loop任务。

所以:你可怜的手指安全了!少打字。不仅因为你只需要编写一次大多数任务定义,而且还因为有时你根本不必编写它们。

特定于语言的任务

在Zed中,越来越多的语言已经定义了任务。 例如,这是你在Rust文件中运行task: spawn时看到的内容

Tasks that come with Zed's language support for Rust
这些是Zed的Rust语言支持附带的任务

这些任务用于运行测试、检查和lint代码、运行代码等等。

语言扩展可以定义自己的tasks.json,然后将其呈现给用户。

这些定义也没有什么特别之处。它们是可以运行的相同的任务,只是它们带有语言扩展。如果你现在想知道是否应该打开一个PR来为你喜欢的语言添加一个tasks.json文件:是的,请这样做!

(值得注意的是,Rust作为我们内部使用最多的语言,因此也是我们的测试平台,有一些特别之处:Rust动态定义了一个$RUST_PACKAGE变量。扩展不能做到这一点,但计划是让扩展也能够定义自己的变量。)

而且,再说一遍:还有更多。

可运行项

如果你使用最新版本的Zed打开一个Rust文件,你不仅会得到一个可以运行的任务列表,还会得到这个

Look in the gutter
看看装订线

看到左侧装订线上的小播放按钮了吗?

是的,你可以点击它们

通过点击按钮执行Rust测试

你在这里看到的是任务,但是Zed如何知道将播放按钮放在测试旁边来运行任务呢?

答案——再次——是Tree-sitter。它是这样工作的。

Zed中的每个语言扩展都可以附带一个名为runnables.scm的文件,其中包含Tree-sitter查询,用于捕获语法树节点,这些节点是*可运行的*:测试函数,main函数——任何真正可运行的东西。

这是当前的Rust runnables.scm

(
    (attribute_item (attribute
        [((identifier) @_attribute)
        (scoped_identifier (identifier) @_attribute)
            ])
        (#eq? @_attribute "test"))
    .
    (attribute_item) *
    .
    (function_item
        name: (_) @run)
) @rust-test

如果你以前从未见过Scheme或Tree-sitter查询,这会让你感到陌生。但是它所做的事情并不那么复杂。

Tree-sitter查询描述了一种语法节点模式(在模式匹配的意义上),用于匹配语法树。这个特定的查询匹配一个语法树节点,该节点首先具有一个attribute_item,其标识符为"test"。然后,该查询允许任意数量的其他属性项——(attribute item) *——然后它要求有一个function_item,它从中获取name属性并将其放入变量@run中。如果一个语法节点匹配此模式,则将其标记为@rust-test

当你在 Zed 中运行 debug: open syntax tree view 时,你可以看到给定文件的语法树。对于这个测试文件,我们可以看到测试函数符合 runnables.scm 文件中描述的模式。

Tree-sitter syntax tree for Rust test functions
Rust 测试函数的 Tree-sitter 语法树

一旦一个节点被标记为 @rust-test,作为一个*可运行项*,剩下的问题是:如何运行它?

这就是 tasks.json 文件发挥作用的地方。这是一个 tasks.json 文件的例子。

{
  "label": "cargo test function",
  "command": "cargo",
  "args": ["test", "$ZED_SYMBOL"],
  "tags": ["rust-test"]
}

这里的新内容是 "tags" 属性。这里包含 rust-test,这也是我们标记可运行语法节点的内容,以及 runnables.scm 和 Tasks 连接的方式。

  1. runnables.scm 中的查询可以匹配任何“可运行的”语法节点,并给它们一个标签。
  2. tasks.json 文件中的任务定义可以包含 tags
  3. 如果 Zed 在节点和任务定义之间找到匹配项,它会在每个节点旁边放置一个播放按钮,以便用户可以使用定义的任务来运行它。

我再说一遍:这非常强大,因为任何东西都可以被标记为可运行的,并且任何东西都可以作为任务执行。想象一下可能性!

您可以标记整个测试套件,而不仅仅是测试函数。或者,您可以以不同的方式标记不同的测试,这样您就可以拥有 integration-testunit-test,或者 fast-testslow-test

或者您可以标记 main 函数、SQL 语句或 在 Markdown 文件中使用 graphviz 执行的 dot 代码块,或者...

换句话说:快去试试 Tasks,让我们知道您的想法,并愉快地进行 Zed 扩展的开发!