Zed Decoded: Syntax-Aware Task Spawning With Tree-Sitter
May 21st, 2024
Have you ever wanted to execute code from inside Zed? Run tests, or a linter, or the compiler, or maybe a script, or a shell one-liner?
Watch:
What you just saw was me using Zed Tasks to execute a Go test from inside Zed, passing the name of the current function to the go test
command.
Tasks, as a new feature, first landed in Zed all the way back in February, in v0.124.7.
But since then they've been improved continuously by Piotr, Kirill, and Mikayla. Now, in the latest Zed Preview release, v0.136, they're frankly impressive.
They're simple in the best sense of the word and at the same time powerful. They also use some very neat Tree-sitter technology under the hood, which is why I wanted to dig into them.
Companion Video: Syntax-Aware Task-Spawning With Tree-Sitter
This post comes with a 1hr companion video, in which Thorsten talks to Piotr and Kirill, who (along with Mikayla) built Tasks. Together they explore all the different ways on how to run Tasks and then deep-dive into their implementation.
Watch the video here: https://youtu.be/se3zS2ZVvMo
Running tasks
First things first. How do you run tasks? With Zed open, hit cmd-shift-p
to open the command palette, and type in task: spawn
.
You get another modal in which you type in the command you want to execute. opt-return
starts the task. Like this:
Now, again, hit cmd-shift-p
, but this time type in task: rerun
.
Like the name suggests, this reruns the last task you executed. If you ran multiple different tasks, it's always the last one that gets re-ran. (Instead of opt-return
to start a task, you can also use cmd-opt-return
, which will cause the task to be run as an ephemeral task — a task that won't be marked as "last run task".)
If you think that's too much typing and "oh my poor hands": there are keybindings to spawn and rerun tasks - opt-shift-t
is bound to task: spawn
and opt-t
to task: rerun
.
Okay, so far, so good. Everybody who's ever wanted to executed code with keybindings just leaned back and sighed "finally."
There's more.
Task variables
In that little intro video above you saw me use $ZED_SYMBOL
to refer to the function the cursor was in when running a task.
$ZED_SYMBOL
is powered by Tree-sitter and populated to contain the name of the last symbol containing the current cursor location. That should correspond to the last symbol you can see in the breadcrumbs at the top of the pane in Zed.
There are more variables like that available:
$ZED_FILE
refers to the absolute path of the currently open file$ZED_ROW
and$ZED_COLUMN
contain row/column of the cursor$ZED_WORKTREE_ROOT
is the absolute path to the root folder of the worktree in Zed
You can find the full and up-to-date list of variables here but I want to highlight one of them here: $ZED_SELECTED_TEXT
.
Evaluating code with Tasks
$ZED_SELECTED_TEXT
contains — yup, you did guess it — the currently selected text. That might not sound like much, but it is powerful.
Take a look:
In this video I'm spawning a task that passes the $ZED_SELECTED_TEXT
to psql
, the PostgreSQL CLI tool.
I select the first SQL statement, spawn the task, then select the next one, rerun the task, select the next one, rerun the task, select the last one, rerun the task.
If you paid attention or already played around with tasks, you might be thinking: wait, how did you rerun the task so that $ZED_SELECTED_TEXT
always contains the latest selection and not the first one with which you ran the task?
The answer lies in this keybinding that I added to my personal Zed keymap.json
:
Ignore the "context"
and the Vim-mode specific stuff, the important bit is this: { "reevaluate_context": true }
.
With reevaluate_context
set to true
I can always rerun the last task and have the variables — $ZED_SELECTED_TEXT
, or $ZED_FILE
, ... — be re-evaluated.
And there are more variables for task::Rerun
too: allow_concurrent_runs
and use_new_terminal
. In the companion video, you can watch how Piotr and Kirill explain these variables to me and how to best use them.
That's some powerful stuff, I'm telling you: with tasks you can evaluate complete files, lines of scripts, selections, ... Sky's the limit! Or, well, what you can run in your shell.
But maybe you're again thinking "oh my poor hands" because you saw me type in these commands and variables and think that, surely, there has to be a better way? There is.
Defining tasks
You can define tasks by using task templates. These are JSON files in which you can define multiple different tasks and make use of task variables.
Task templates can go in two different places:
- In a
.zed/tasks.json
file in your project's root folder (usezed: open local tasks
to create/open that file) - In a global
~/.config/zed/tasks.json
file (usezed: open tasks
to create/open that file)
Here's an example of such a file:
With this file in .zed/tasks.json
, I get the following modal when I run task: spawn
:
If you define tasks like this, in a tasks.json
file and with label
s, then you can also create keybindings to spawn specific tasks. Example:
This would spawn the My cool loop
task from above.
So: you're poor fingers are safe! Less typing. Not just because you only have to write most task definitions once, but also because sometimes you don't have to write them at all.
Language-specific tasks
In Zed, a growing number of languages comes with tasks already defined. Here, for example, is what you see when you run task: spawn
in a Rust file:
These are tasks to run tests, to check and lint the code, to run it, and so on.
Language extensions can define their own tasks.json
, which are then presented to the user.
- Elixir already has a
tasks.json
- Gleam also has
tasks.json
- @RemcoSmitsDev is working on adding
tasks.json
to PHP
There's nothing special about these definitions, either. They're the same tasks that you can run, except they come with language extensions. If you're now wondering whether you should open a PR to add a tasks.json
file for your favorite language: yes, please!
(It's worth noting that Rust, as our most-used language internally and thus our test bed, has something special: Rust dynamically defines a $RUST_PACKAGE
variable. Extensions can't do that yet, but the plan is to give extensions the ability to define their own variables too.)
And, again: there's more.
Runnables
If you open a Rust file with the latest version of Zed, you will not only get a nice list of tasks to run, but you'll get this:
See the little play buttons on the left side, in the gutter?
Yes, you can click them:
What you're seeing here are tasks, too, but how does Zed know to put the play button next to the tests to run the tasks?
The answer — again — is Tree-sitter. Here's how it works.
Each language extension in Zed can ship a file called runnables.scm
that contains Tree-sitter queries to capture syntax tree nodes that are runnable: test functions, main
functions — anything that's runnable, really.
Here's the current Rust runnables.scm
:
If you've never seen Scheme or Tree-sitter queries before, this will look alien to you. What it does is not that complicated, though.
A Tree-sitter query describes a pattern (in the pattern-matching sense of the word) of syntactic nodes to match against a syntax tree. This particular query matches a syntax-tree node that first has an attribute_item
whose identifier is "test"
. Then the query allows for an arbitrary number of other attribute items — (attribute item) *
— and then it requires there to be a function_item
of which it takes the name
attribute and puts it in the variable @run
. If a syntax node matches this pattern, it's tagged as @rust-test
.
When you run debug: open syntax tree view
in Zed, you can see the syntax tree for the given file. And in the case of this test file, we can see that the test functions match the pattern described in the runnables.scm
file:
Once a node has been tagged with @rust-test
, as a runnable, the question remaining is: how do we run it?
That's where the tasks.json
come back in. Here's an example tasks.json
:
What's new here is the "tags"
attribute. This one here contains rust-test
, which is also what we tag our runnable syntax node with and exactly how the runnables.scm
and the Tasks are connected:
- The queries in
runnables.scm
can match against any syntax nodes that are "runnable" and give them a tag - The task definitions in a
tasks.json
file can containtags
- If Zed finds a match between a node and a task definition, it puts a play button next to each node so the user can run it with the defined task.
I'm repeating myself, but: this is powerful, because anything can be tagged as runnable and anything really can be executed as a task. Imagine the possibilities!
Instead of just tagging test functions, you can tag entire test suites. Or you can tag different tests in different ways, so you can have integration-test
and unit-test
, or fast-test
and slow-test
.
Or you can tag main
functions, or SQL statements, or dot
code blocks in Markdown files to be executed with graphviz, or...
In other words: go try out Tasks, let us know what you think, and happy hacking on Zed extensions!