Writing Neovim plugins in Rust

For someone like me who isn’t a VimScript guru, this was a very welcome change.

I recently wrote a plugin in Rust to control the Spotify desktop app for MacOS and find lyrics from within Neovim and found it to be a neat experience overall, and that’s coming from someone who isn’t a Rust expert.

This post is a summary of all the research and discovery that happened during that process.

The overarching idea is fairly simple and can be summarized in two steps:Write a Rust application that can receive RPC messages from Neovim and “do stuff”.

For example, Neovim sends a “get_current_song” RPC and the Rust application will query Spotify for the current song and echo it to the Neovim command line.

Write the Neovim “glue” code.

This is a small bit of VimScript, that does two things.

First, it spawns a “job” using your Rust binary.

A “job” is Neovim speak for attaching a remote plugin to an instance.

Secondly, it attaches specific Neovim commands to RPC invocations.

For example, the :SpotifyCurrentSong command will send an RPC message called “get_current_song” to your Rust code.

Note that you can substitute Rust for any compiled language, say Go, and still follow the same two steps.

Of course, the client libraries you end up using will have idiosyncracies that you need to work with.

An example plugin: CalculatorLet’s explore what each of those steps looks like by creating a small plugin called Calculator.

We will look at a simple way to organize your code — YMMV.

To see the finished product, refer to the following repositories on GitHub:neovim-calculatorneovim-spotifyI’ve tried to be as verbose as possible, because there’s a good chance my future self will read this when I try to write a plugin again, and I would rather there be a step-by-step guide when that happens.

Feel free to skim parts you don’t think are relevant to you.

As the self-explanatory name probably suggests, the Rust code will be responsible for performing the computations and echo-ing back results.

For brevity, we will only create two commands, :Add and :Multiply .

Each of these commands take two arguments and echo out the result to the command line.

For example, :Add 2 3 will echo Sum: 5 and :Product 2 3 will echo Product: 6 .

Create a new Rust binary project using Cargo.

$ cargo new –bin neovim-calculatorNext, pull in the Neovim RPC client library for Rust, neovim_lib.

The documentation is your friend here, keep it close.

Add the dependency to your Cargo.

toml file.

This is the only dependency we’ll need for this simple exercise.

[dependencies]neovim-lib = "0.


0"The primary responsibilities of the Rust code are to receive RPC messages, associate the right service handlers to each message, and if required, cause a change in the Neovim instance (for example, echo-ing back responses).

Let’s decouple these ideas into two major structs: EventHandler and Calculator .

The EventHandler acts effectively as a controller, receiving RPC events and responding back, and calling the right service methods while the Calculator struct is the “business logic”.

Substitute this with whatever “stuff” your plugin does.

The main function is trivial.

We discuss why we need the event handler to be mut a little later on.

fn main() { let mut event_handler = EventHander::new(); event_handler.

recv();}The calculator serviceThe easy bit first: the Calculator struct.

The following code should be fairly self-explanatory.

I leave the i32 vs.

i64 debates to you.

The event handler serviceNext, let’s write the EventHandler.

First, it will hold an instance of the Calculator struct as a field.

Next, It will embed a Neovim struct from the client library inside it.

If you see the documentation, this struct implements the NeovimApi and NeovimApiAsync traits, which is where all the power lies.

Each Neovim struct instance is created with a Session struct which you can think of as an active Neovim session.

The Neovim struct is just a handle to manipulate this session.

There are various ways to create a Session.

You could use Unix sockets using $NVIM_LISTEN_ADDRESS or connect via TCP.

I will let you figure out what suits your needs the best, but the simplest, in my opinion, is to attach to the “parent” instance.

Typed RPC messages using Rust’s enumsFinally, we can get to handling events via the recv() method.

A small precursor to complete before that: RPC messages are received as strings by the neovim_lib library.

But, we can map each of these strings into an enum to provide us with all the benefits of Rust’s enum types like exhaustive matching.

We implement the From trait, to convert between an owned String and a Messages enum.

Subscribing to RPC messagesThere are several ways to subscribe to RPC events coming from Neovim, all of which involve starting an event loop using the session we created earlier.

Again, you might want to read the documentation to know which one is the best for you, but for our purposes, start_event_loop_channel is perfect, because it gives back an MPSC Receiver which we can iterate over to receive events and the arguments with which the command was called.

Creating this receiver requires recv() to take a &mut self , which is the reason our original main() function also specified event_handler as mut.

Note that we do not have a session as part of the EventHandler state — that would get us into issues with the borrow checker since the Neovim struct that we subsequently created required the session to be “moved” into it.

Fortunately, the Neovim struct that we created exposes the session as a public field which we can use.

Converting between Neovim and Rust data typesAlmost there!.Next, we need to serialize the valuesto the right Rust data types and invoke the calculator functions.

Enter the Value enum.

We are only interested in the Integer variant but you know what to do for your particular use case.

From the same document, we see that we can use the as_i64 impl to get an Option<i64> from the Value .

We’ll simply unwrap() the Option for now, but this is a good spot to validate user input.

With a bit of idiosyncratic iterator usage, the serialization is pretty declarative.

Note that it is EXTREMELY easy to get into a rabbit hole of trial and error where data types from Neovim don’t serialize into the right Rust data types.

This is an issue in general with cross-language RPC.

We will take special care in our Neovim code to ensure that we are always passing integers.

Executing arbitrary Neovim commandsFinally, we will echo back the responses to the Neovim instance.

This is done by the command method on the NeovimApi trait.

Again, forgive the unwrap s.

Neovim “glue” codeWe’re done with the Rust code!.Let’s look at the Neovim “glue” to attach commands to specific RPC by diving into a little bit of VimScript.

Create a plugin/neovim-calculator.

vim file that will load up when anyone installs this plugin.

While you can just copy paste the final result and only a few tweaks will get your plugin running, there are a few concepts to grok if you want to know what’s going on.

As with the Rust code, documentation is your friend — run :h <command_or_function> to know what it does.

We first need to create a “job” using the jobstart function.

We will start a job using our Rust binary and in RPC mode.

The jobstart function gives us back a job-id .

In true C-style, this value will be 0or -1 if the job cannot start.

Otherwise, this job-id identifies a unique “channel”.

See :h jobstart() for more information.

Channels are what allow message passing between the Neovim and the Rust process.

Since we are in RPC mode, the rpcnotify function will help us send messages.

Again, see :h rpcnotify() to learn more.

Finally, we need to attach specific commands with specific rpcnotify invocations.

For example, :Add should call rpcnotify with the add message, which is what the Rust code recognizes.

Same goes for :MultiplyNote that the Neovim job-control API is fairly complex and powerful, and I leave it to you, the reader, to investigate the intricacies.

Connecting to the Rust binaryAlright, on to the code!.First, let’s initialize a local variable calculatorJobId and set it’s value to zero, which if you have followed the steps above, will know that it is an error value.

Next, we will try to connect to the binary using jobstart which is done by the initRpc function.

If the calculatorJobId is still zero, or possibly -1, then we could not connect.

Otherwise, we have a valid RPC channel.

Local variables and functions are initialized using the s: prefix.

This way, the variable and function names do not pollute the global namespace.

Hook up commands to RPC invocationsAfter we conclude that we have a valid channel, we will configure the commands to their RPC invocations.

This is done by the aptly named command!.command.

There is quite a bit going on here.

Firstly, we added the call to configureCommands after we got the channel ID.

Next, configureCommands added two user-defined commands, :Add and :Multiply .

The -nargs=+ attribute specifies that the commands each take one or more arguments.

We call the function s:add with <f-args> when we receive the :Add command.

<f-args> just means that s:add will be called with the same arguments that :Add was called with.

The same goes for :Multiply .

Finally, onto the s:add and s:multiply methods themselves.

The function signature has a strange .

argument, which is VimScript speak for variadic arguments.

Next, we extract the first and second argument from the user input using get .

Lastly, we call rpcnotify with the right RPC identifier, and the two arguments.

Note that rpcnotify was explicitly given two arguments which were converted to numbers using str2nr .

Not two strings, not a list of strings, not a list of numbers.

Be wary of how you’re passing data in your RPC calls, so that it matches up with how you’re parsing it on the other side.

Running the pluginAnd that’s the VimScript side of things!.To try this plugin out, you have a couple of options.

Add this directory to your runtimepath .

See :h runtimepath to know more.

If you are using vim-plug , you can add the following line to your init.

vimPlug '/path/to/neovim-calculator'If all went well, you should have the two commands working!TroubleshootingThere is zero visibility in this entire process unless you do it manually.

To debug on the Rust side, the easiest way is to create a file, and check for an environment variable such as NVIM_RUST_LOG_FILE then write to it the same way you’d write logs in general.

Read by tail -f ing the file.

On the Vim side, use echo and echoerr .

Also, keep your fingertips enthusiastic to jump to help documentation with :h .

Next stepsAs it stands, our plugin cannot be installed by the general public because they wouldn’t have the binary to connect to.

An option is to provide a shell script that downloads the pre-built binary from your GitHub releases, falling back to manual Cargo builds if needed.

If you want a reference, consult the install script for the Spotify plugin I made.

Of course, you would have to set up your pipeline to cross-compile and release to GitHub.

I leave it as an exercise to you, the reader.

ConclusionYou can extend the concepts above to any compiled language because the VimScript side stays the same, and there are equivalent client libraries for several platforms.

Hopefully, that was a decent guide to getting a Neovim plugin working via RPC and it helps someone out!.(looking at you, future self!).

. More details

Leave a Reply