Hazard

A Data-Driven Clojure API for Neovim February 7, 2018

I switched from Vim to Neovim a few months ago so I could script my editor with Clojure. Neovim-client provides a Clojure API for Neovim’s TCP interface. In general it’s worked well for me, though I did run into a problem when I wanted to use neovim-client.1.api/call-atomic. The function expects a list of Neovim version 1 API messages as vectors, but neovim-client offers no way of constructing such a list short of coding them yourself. You have to do something like this:

(require '[neovim-client.nvim :as nvim])
(require '[neovim-client.1.api :as api])

(def client (nvim/new 1 "localhost" 7777))

(let [buffer (api/get-current-buf client)
      window (api/get-current-win client)]
  (api/call-atomic client
    [["nvim_buf_set_lines" [buffer 0 0 true ["Hello, World"]]]
     ["nvim_win_set_cursor" [window [2 0]]]]))

While hardly onerous, it does reveal a design flaw. When using call-atomic, neovim-client’s primary abstraction breaks down and we’re forced to work at two levels of abstraction: library functions plus message vectors. Neovim-client has complected message construction with message execution.

So let’s wrap neovim-client with a data-driven API and see what kind of power it gives us.

First, we’ll create functions that construct Neovim messages without executing them. I’ll tag these vectors with a keyword that identifies them as Neovim messages.

(defn get-current-buf []
  ^:nvim-call ["nvim_get_current_buf" []])

(defn get-current-win []
  ^:nvim-call ["nvim_get_current_win" []])

(defn buf-set-lines [buffer start end strict-indexing replacement]
  ^:nvim-call ["nvim_buf_set_lines" [buffer start end strict-indexing replacement]])

(defn win-set-cursor [window pos]
  ^:nvim-call ["nvim_win_set_cursor" [window pos]])

(defn execute [client [message args]]
  (apply nvim/exec client message args))

Our original interaction with Neovim now looks like this:

(let [buffer (execute client (get-current-buf))
      window (execute client (get-current-win))]
  (api/call-atomic client
    [(buf-set-lines buffer 0 0 true ["Hello, World"])
     (win-set-cursor window [2 0])]))

Pre-fetching the buffer and window is a little annoying. We can use Clojure’s data transformation functions to nest API calls within the arguments of other calls. Here’s an execute function that recursively executes messages, finding nested calls using our metadata tag.

(require '[clojure.walk :refer [prewalk-replace]])

(defn execute [client calls]
  (if-let [dependencies (seq (into #{}
                                   (mapcat (fn [[_ args]]
                                             (filter #(:nvim-call (meta %)) args))
                                   calls)))]
    (let [[values err] (execute client dependencies)]
      (if err
        (throw (Exception. err))
        (let [call->value (zipmap dependencies values)]
          (api/call-atomic client (prewalk-replace call->value calls)))))
    (api/call-atomic client calls)))

Now we can write,

(execute client
  [(buf-set-lines (get-current-buf) 0 0 true ["Hello, World"])
   (win-set-cursor (get-current-win) [2 0])])

execute offers two possible reductions in RPC overhead. First, it fetches the buffer and window in one Neovim call rather than two. Second, any identical messages that appear in different calls are only fetched once. In practice, these gains are negligible, as communication with Neovim is quite fast, but it does illustrate how a data-driven API and data rewriting can automate some optimizations in a way that a code-driven API can’t.

The execute function is the basis for a thin wrapper library I’ve written around neovim-client. If you have any questions or ideas, I’m open to suggestions. You can contact me on Twitter or by email .