Zed Decoded: Why not just embed Neovim?

Usually when I tell people that I've switched to Zed as my main editor, after something like 15 years of using Vim, the first question they ask is: don't you miss Vim? Then I tell them: Zed has a Vim mode. I don't think I would've or could've switched if it didn't.

Then, surprisingly often, there are follow-up question that sound something like this: a Vim mode? Did you know that Neovim is embeddable? Why doesn't Zed just embed Neovim?

So in this Zed Decoded episode, let's dig into Zed's Vim mode and find some answers to these questions.

Companion Video: Why not just embed Neovim?

This post comes with a 1hr companion video, in which Thorsten talks to Conrad, who worked a lot on Zed's Vim mode in the last few months and improved it tremendously. Together Thorsten and Conrad explore Zed's Vim mode, what's tricky about implementing it, why we don't embed Neovim and how we do use Neovim.

Watch the video here: https://youtu.be/Ys8-KkzH5Rc

Zed's Vim mode

First things first: Zed has Vim mode. You can enable it by adding the following to your Zed settings:

{
  "vim_mode": true
}

Once you add that and save your settings, you'll see your cursor change into a block, which means you're ready to explore Zed's Vim mode:

  • h, j, k, l are ready to go.
  • You can use motions like w, W, e, E, b, B, {, }.
  • There's also f and t and ; and ,.
  • Use v, V, and ctrl-v to enter different visual modes.
  • There are a lot of g commands, such as gg, gn, gx, gt.

But Zed's Vim mode doesn't just support the standard motions and operators:

  • Basic parts of vim-surround already work: you can use cs"' to change surrounding " into ', or ds[ to delete surrounding [. Even the vim-surround kung-fu monster combo of ysiw" works.
  • Code comments can be toggled with gcc in normal mode and gc in visual mode.
  • Many of Vim's "window management" keybindings starting with ctrl-w have equivalents in Zed's Vim mode.
  • Some more advanced Vim features also work. Try searching for the word under the cursor with *, change the next occurrence with cgn, and then repeat with ..
  • Buffer-local marks ('a to 'z) and some builtin marks ('<,'>,'[,'], '{, '} and ^) work.
  • Basic support for named registers has just landed on our main branch and will be in the next Preview release of Zed.

There is a lot in Zed's Vim mode. The official docs page on the Vim mode give you a good overview of what's possible and what can be configured. You can also read through the default Vim keybindings to see which motions and operators are supported.

But it's not complete yet. Not yet, anyway. Some big things are missing, such as registers and macros, and there's a long tail of operators and motions that still need to be added. Our versions of the jumplist and changelist also need some tweaking to make them more Vim-like and consistent.

The good news is that it is and has been steadily improving. Basic support for marks has been added last month, surrounds landed in the month before that, gn and cgn were also merged two months ago. Conrad alone merged more than 20 PRs into Zed's vim crate in the last two months.

And, wow, witnessing the improvements and participating in some of them was eye-opening.

Operator by operator, count by count

As a long-time Vim user I already knew quite a bit about Vim and as a new Zed developer working on its Vim mode, I half-expected there to be arcane Vim operators and motions that need to be implemented. I knew that there's a lot of stuff in Vim, but was still surprised by oh wow, there is a lot of stuff in Vim — operators, and motions, and modifiers, and combinations of all of them — and how many things that I would've considered to be rarely used features are widely used.

Did you know, for example, that gs in Vim stands for "goto sleep" and makes Vim, well, go to sleep for N seconds? Yes, that came up in our issue tracker, when we naively thought that gs wasn't taken yet as a keybinding.

Or, maybe you already knew z., which is similar to zz, and centers the current line. But did you know that it takes a count? Yup: you can do 5z., which centers line 5. I didn't know that.

You surely know i, though, the command that takes Vim into insert-mode and probably the second thing a new Vim user learns after :q. But did you know that i takes a count too? You can use 5ifoobar<esc> to insert foobar 5 times. And, yes, a takes a count too.

Talking about counts: have you ever sat down and pondered the difference between 5dj and d5j? We did, in the companion video, and Conrad did more than ponder when implementing counts for more operators.

Or, let me ask you this: would you suspect that there are people using r and R — both trigger replace mode — in their daily Vim workflow? Let alone that these Vim users would put money where their issue-opening mouth is and pay $500 to whoever implements r and R?. I certainly didn't. I mean, I knew about R but I thought that, surely, no one really ever uses it.

Or the . command, everybody's Vim darling. A simple command, a single ., that does a simple thing, right? It just repeats what you did last. Except that doesn't quite capture it: it only repeats the last change to a buffer, not navigation. But it also doesn't repeat a completion action, for example, that did change the buffer. When put like that, it sounds obvious — "yes, uh-huh, that's what . does, I knew that" — but it's really easy to hit your toes on these subtle details when you're trying to build a generic . command.

Different foundations

Why am I telling you about these surprising operators and combinations? To share a realization: when you're trying to build a Vim mode that's as complete as possible and you keep bumping into these subtleties, you realize that Vim and Zed sit on different foundations.

Vim, for example, addresses characters in the buffer. Zed, on the other hand, addresses the slots between characters.

That's the difference between the cursor in abc sitting on the b (Vim) or sitting between a and b (Zed). As you can imagine, the ripple effects of an invariant like that turn into waves five abstraction layers up.

Consider how both editors handle newlines: Vim distinguishes between the end of the line and the last character in the line. In practice that means you can, for example, create a visual selection until the end of the line with v$ and then additionally select the newline character by hitting l, so that a deletion with d would then delete the complete line, but it looked like you only ever had the first line selected.

In non-Vim-mode Zed you can do a similar thing and select until the end of the line. That selection, though, doesn't include the newline as long as your cursor stays on that line. As soon as you select the newline character, your cursor pops down to the next line.

In Zed's Vim mode we try to address (or: work around) these differences as much as possible to make the Vim mode as Vim-like as possible. To quote our documentation on the Vim mode:

Vim mode in Zed is supposed to primarily "do what you expect": it mostly tries to copy Vim exactly

That's not only hard and tricky work (see this issue to get an impression of which edge cases are involved), but there is also a limitation, a ceiling to this effort: we don't want to throw away Zed's foundations. There's a lot attached to them.

That's why it's not embedded

See, if you were to embed Neovim into Zed, you'd end up doing exactly that: you would throw away Zed's foundations and replace them with Neovim.

But these foundations — the data structures to represent text, the CRDTs, the render pipeline, the custom Async Rust runtime — are what make Zed Zed: a high-performance, collaborative text editor. Or, to use the phrase from the CRDT blog post: the CRDTs, the Rope, the SumTree, the text models — that's Zed's DNA. Zed was built on the realization that you can't just add collaboration on top, it needs to be built-in, from the ground up.

If we were to put Neovim into Zed, we'd have to throw this DNA away when Vim mode is enabled — which we don't want — or port the DNA over to Neovim. (Cue the animation of someone doing gene splicing, with lots of sweat on their forehead.) That means, we'd have to do a lot of things twice: once in Zed and once in the embedded Neovim. Build CRDTs twice, build multi-buffers that multiple people can edit at the same time twice, and so on.

Building these things once is already hard. It's a lot of work that's hard to get right. Building it twice in two different codebases is... well, at least twice as hard.

So, there you have it. That's why we don't just embed Neovim into Zed. Instead, we built a Vim mode inside Zed. And that, I personally think, is more interesting than just embedding Vim.

Vim and Zed, melded

Since Zed's Vim mode sits on top of Zed's foundation, what you get is a combination of both. When Vim mode is enabled, you can still use almost everything that's available in non-Vim-mode Zed.

For example: you can use gl to create an additional cursor that sits on top of the next occurrence of the current word. Or use gL to do the same but backwards. Use either one, then hit <esc>, and you're left with multiple cursors, but in Vim normal mode, with every motion and operator we have available.

Or, try this: use ]x and [x to select a Treesitter syntax node. These selections you can also combine with multi-cursors. So if you have a Treesitter node selected, hit gl and it will create another cursor on a node that looks the same.

Recording of me doing exactly that

Or hit :. That not only opens the Zed command palette, there's also bindings and shortcuts for common commands such as :w. :E[xplore] opens the project panel, :te[rm] the terminal, and so on.

g] and g[ navigate between diagnostic errors, ]c and [c between git changes. gs opens the symbol outline in the current file, gS does the same, but globally for the project. g. opens code actions. Take a look at the Zed-specific features of the Vim mode to find out what else you can do in Zed's Vim mode. If something's missing you can always fallback to cmd- shortcuts or open the command palette, find a command, and create a custom binding.

Here, for example, are some bindings that I have in my Zed keymaps.json and that I use in the Vim mode:

[
  {
    "context": "EmptyPane || SharedScreen || vim_operator == none && !VimWaiting && vim_mode != insert",
    "bindings": {
      ", f b": "tab_switcher::Toggle",
      ", f i": "file_finder::Toggle",
      ", f o": "projects::OpenRecent",
      ", r l": "task::Rerun",
      ", r e": ["task::Rerun", { "reevaluate_context": true }],
      "ctrl-s": "projects::OpenRecent"
    }
  },
  {
    "context": "Editor && VimControl && !VimWaiting && !menu",
    "bindings": {
      "g shift-r": "editor::FindAllReferences",
      "g a": "editor::ToggleCodeActions",
      "g r": "editor::Rename",
      "space w": "workspace::Save",
      ", g b": "editor::ToggleGitBlame"
    }
  }
]

Neovim... at last

Now that you know why we won't embed Neovim and what advantages that might have, let me send you home with something really neat that you can share with other programmer friends over drinks: we do use Neovim in Zed, but we use it in our tests.

In the companion video, Conrad explains how it works in detail, so here's the short version.

All of Zed's Vim mode is contained within a single crate, vim, and in that one, some tests look like this:

#[gpui::test]
async fn test_visual_star_hash(cx: &mut gpui::TestAppContext) {
    let mut cx = NeovimBackedTestContext::new(cx).await;
 
    cx.set_shared_state("ˇa.c. abcd a.c. abcd").await;
    cx.simulate_shared_keystrokes("v 3 l *").await;
    cx.shared_state().await.assert_eq("a.c. abcd ˇa.c. abcd");
}

The interesting bit here is NeovimBackedTestContext::new(): that causes the test to run a headless Neovim instance, send the initial state to it, and then simulate the keystrokes. The final state produced is then saved to a JSON file and against that state we test Zed's Vim implementation.

In other words: we use a headless Neovim in tests to produce "golden files" against which we check what Zed's Vim mode produced with the same keystrokes.

Pretty neat, right?