Vim Clojure Tooling Redux

A year ago I switched from Fireplace, to a custom SocketREPL-based Neovim plugin, written with Clojure. I’ve talked about my motivations for doing so previously. In short, when I’m editing Clojure code with (Neo)Vim, most of the time I only want to evaluate some S-expression. I don’t really need or even want most of Fireplace’s power. After a year of living with my SocketREPL plugin, I can honestly say that I’ve truly enjoyed its simplicity, and having the capability to write a plugin using Clojure. But, since I’m being honest, it bothers me that running this plugin involves firing up a JVM process, and a few hundred lines of code, just to do what amounts to little more than writing some text to a socket.

Speaking of Neovim, I recently came across a StackExchange post which explains how to interact with a Neovim:terminal using Neovim's job API. Maybe this is well known behavior, but it was news to me. So, I took a crack at using this technique to achieve the same functionality as my SocketREPL plugin.

  • Evaluate a highlighted form, or the next form, if the cursor is on a (, {, or [.
  • Evaluate the entire buffer, using load-file.
  • Get the doc for the word under the cursor.

It took a shockingly small amount of VimScript to accomplish this.

augroup Terminal
au TermOpen * let g:term_jid = b:terminal_job_id
augroup END
function! REPLSendSafe()
" Hack to get character under the cursor.
norm "ayl
if index(["(", ")", "[", "]", "{", "}"], @a) >= 0
" Hack to get text using % motion.
norm v%"ay
call REPLSend(@a)
function! REPLSend(cmd)
call jobsend(g:term_jid, a:cmd."\n")
" If no visual selection, send safely
nnoremap <leader>ef :call REPLSendSafe()<cr>
" If there's a visual selection, just send it
vnoremap <leader>ef "ay:call REPLSend(@a)<cr>
" Send the entire buffer
nnoremap <leader>eb :call REPLSend("(load-file \"".expand('%:p')."\")")<cr>
" Get docs
nnoremap <leader>doc :call REPLSend("(clojure.repl/doc ".expand("<cword>").")")<cr>

That's it!

To be fair, I still use paredit.vim and clojure-vim-static.

After sourcing this script (or put it in your .vimrc), you simply create a new buffer, start a :terminal, and create a REPL connection. You can use a SocketREPL, use lein repl, however the kids are doing it these days.

This setup is utterly transparent. Or maybe “anemic” is a better word. You're sending code to the REPL via the exact same stdin stream which you use to type commands into it. There's absolutely no question about which namespace your code is evaluated against, or which REPL process you're connected to, or whether code is being sent to a Clojure or ClojureScript REPL, etc. No middleware, plugins, or magic of any sort enchants your REPL’s process without you watching its code scroll by.

Jump to Definition

I'm a little reluctant to admit it, but I miss jump to definition. I haven't been able to use gf or <C-]> since I gave up Fireplace. At the time, I was spending most of my time in ClojureScript and support for jump to definition was pretty flakey. Recently, while rationalizing why I don't actually need this feature, I was reminded of ctags, as an alternative. Again, I was surprised by how simple this was to get up and running.

Typically, I'll ctags -R src dev ... from a project root, as needed, with this .ctagsconfig, and use a tiny bit of VimScript to create the mapping.

“ Strip off the symbol’s namespace
function! SanitizeTag(word) return (split(a:word, ‘/’)[-1]) endfunction
nnoremap tt :exe “:tag “.SanitizeTag(expand(“<cword>”))

I've used this with some fairly large projects, as well as projects with clj, cljs, and cljc source code, and it seems to hold up pretty well.

Wrapping Up

At some point in their journey, most Neovim users ponder whether to use the :terminal, or stick with tmux. I'd been hesitant to commit to it, but using this setup is definitely motivating me to give :terminal a serious go.

I'll admit that this config is pretty spartan, and it's not for everyone. But if you're like me, and you're not convinced that Omnicompletion is worth a few thousand lines of VimScript, nREPL, and Cider, then give it a shot.

Update 3/2/2018

I’ve added the following mapping to run tests in the current namespace. This code assumes that (ns ...) is the first form in the buffer, which is true enough of the time, in my experience.

nnoremap <leader>tb :norm gg,ef<cr>:call REPLSend("(require '[clojure.test]) (clojure.test/run-tests)")<cr>
One clap, two clap, three clap, forty?

By clapping more or less, you can signal to us which stories really stand out.