Clojure for Neovim for Clojure

I started programming in Clojure in 2011, more or less the early medieval period of the language. Back then I did what many other Vim based programmers did: I wrote code in a Vim buffer while running a REPL in a separate window. If I was feeling ambitious I might use Vimshell. This setup did work, but as a longtime Java and Python programmer I missed the smoothly integrated tooling of those languages.

By 2012 things had gotten better. Leiningen now had support for nREPL, which enabled Tim Pope to create vim-fireplace. Vim-fireplace didn't quite have feature parity with Emacs + Cider, but it was capable. For example, at the 2013 Lambda Jam Chris Ford gave an excellent, highly interactive talk, where he composed music using vim-fireplace.

An End to VimScript Hell

But like rust, programmers never sleep and the day soon came when I was ready to start tinkering with my Clojure tooling. And that's when I came face to face with the 2,000 lines of VimScript that is vim-fireplace. While I'm grateful to Tim Pope for the time and energy he spent creating vim-fireplace, all of that stuff just overwhelmed me. If you have never experienced it, let me put it this way: People have described VimScript as a "baroque, deranged and annoyingly useful mess" and the agonies of the vim-fireplace code seemed to bear this out. So as excited as I was about Lisp and Clojure, taking the time to learn VimScript was not part of the plan.

Everything changed mid-2014 when I discovered Neovim. Neovim is a drop-in replacement for Vim, built for users who want the good parts of Vim -- and more -- without the cruft. Neovim is not a Vim clone so much as its sibling: Neovim rolls in all of the Vim patches and adds some nifty new features. Over time, some of Neovim's features even found their way into Vim.

Neovim also adds some long awaited goodies to Vim:

  • Built in terminal emulator
  • First class embedding
  • Multithreading

But the key feature for me was:

  • RPC API / Remote Plugins

Remote Plugins

Alongside Vim's original in-process plugin model, Neovim also offers a new remote plugin model. Now, plugins can be implemented as arbitrary programs which Neovim runs as co-processes. On top of being able to work asynchronously, remote plugins are also safer: Isolated in a separate process, it's much harder for a remote plugin to block or crash the whole editor. Best of all, since remote plugins are arbitrary programs, you can write them in any language.

Remote plugins establish a direct communication channel to the Neovim process using Neovim's new RPC API, allowing them to:

  • call API functions
  • listen for events from Neovim
  • receive remote calls from Neovim

This new remote API is accessible via a TCP or Unix Domain socket, as well as Standard IO, and uses MessagePack-RPC, an asynchronous protocol. This means other types of "clients" can access the RPC API as well: GUIs, scripts, even another Neovim process!

This new remote API offers functions to do what you'd expect: Read and update buffer contents, set cursor location, and execute any arbitrary Vim commands.

A Clojure API

Immediately after discovering this, I created a client library for the Neovim API using Clojure, allowing plugin authoring in Clojure. Finally, I had what I'd wanted: I could write entire plugins in Clojure, or simply fire up a REPL and interact with the running Neovim process. For example, to set the current buffer's text:

user=> (require '[neovim-client.nvim :as nvim])
user=> (require '[neovim-client.1.api :as api])
user=> (require '[neovim-client.1.api.buffer-ext :as buffer-ext])

user=> (def conn (nvim/new 1 "localhost" 7777))
user=> (def b (api/get-current-buf conn))

user=> (api/command conn "e") ;; open file
user=> (def lines (buffer-ext/get-lines conn b 0 -1))
user=> (buffer-ext/set-lines conn b 0 (count lines) (map clojure.string/reverse lines))

The example shows the synchronous functions, but the library also provides non-blocking semantics.

Even better, since Neovim can be executed headlessly via nvim --embed, I was able to write tests which exercise the API against an actual Neovim process! with-neovim is a macro which creates & tears down the nvim process (for each test).

(deftest change-buffer-text
    (let [{:keys [in out]} *neovim*
          conn (client.nvim/new* 1 in out false)]
      (let [b1 (api/get-current-buf conn)
            _ (api.buffer/set-lines conn b1 0 1 false ["foo"])
            _ (api/command conn "new")
            b2 (api/get-current-buf conn)
            _ (api.buffer/set-lines conn b2 0 1 false ["bar"])]
        (is (= ["foo"] (api.buffer/get-lines conn b1 0 1 false)))
        (is (= ["bar"] (api.buffer/get-lines conn b2 0 1 false)))))))

A Simple Tool

While many of the earlier Clojure integrations like vim-fireplace were based on nREPL, which was fairly complex, life got a lot easier with the Socket server REPL. Introduced with Clojure 1.8, the Socket server REPL meant that you could expose a REPL via a TCP socket from any existing Clojure application, with no additional dependencies, or even code! All you had to do was set a single JVM system property on startup.

$> java ... -Dclojure.server.repl="{:port 5555 :accept clojure.core.server/repl}"

Now, all the necessary pieces were there. With this new, dependency-free REPL, and my Neovim RPC API client library, I was able to create a Socket REPL plugin for Neovim. The plugin simply takes some code from a Neovim buffer, and writes it to the TCP socket connected to the REPL. Whatever it reads from the socket end up in a "results" buffer.

As I say, there are no dependencies beyond the Clojure runtime. And it's written in Clojure, not Vimscript, a drastic reduction in complexity.

To be fair, my Neovim integration provides just the basics:

  • Eval the form under the cursor
  • Eval / load-file the current buffer
  • doc the form under the cursor

In addition to having fewer features than vim-fireplace, clojure-socketrepl.nvim does less automatically.

For example, you always have to explicitly :Connect host:port, rather than automatically detecting the port of the nREPL instance presumably started by Leiningen. There is also only ever one REPL Connection, in contrast to vim-fireplace's ability to simultaneously connect to multiple REPLs and determine which one to use based on a buffer's file path.

On the other hand, when you evaluate code using clojure-socketrepl.nvim, Neovim does not block waiting for a response. All plugin interactions are completely asynchronous. Results accumulate in a buffer as they are read from the Socket REPL connection.

There is also no special treatment for standard IO. When you print something in a go-loop, it shows up in your results buffer exactly as you'd expect when interacting with a REPL. Contrast this with vim-fireplace which returns a string representation of the channel (the result of evaluating the go-loop), and swallows the resulting print output.

As you'd expect, you can interactively develop the plugin at the REPL (using the plugin), but you do need to take care that you don't fall all the way down the rabbit hole.

Final Thoughts

This was a fun experiment, and a great way to squash some bugs in the Neovim API client library. I've been using this Socket REPL plugin for my daily development workflow successfully for a few months now. This may not be the tool for everyone, but I find it useful. It is an order of magnitude less complex than the vim-fireplace tool stack, while retaining much of the core functionality.

Far more importantly though, building clojure-socketrepl.nvim has helped strengthen the Neovim API client library for Clojure. My hope is the client library and possibly the plugin, will help add momentum to an effort to improve the state of Vim tooling for Clojure.

Get In Touch