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!
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)
endif
endfunction
function! REPLSend(cmd)
call jobsend(g:term_jid, a:cmd."\n")
endfunction
" 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>

Update 4/27/2018

Lately, I’ve needed to modify the code of two different processes. For example, if I want to modify a ClojureScript web application and the Clojure backend services it consumes, in lockstep. My flow is simple. I’ll open up a tab per project. Each tab contains a window or three with code belonging to that project, and a :terminal buffer, running that project’s REPL.

Again, the nice part of this setup is that it doesn’t matter how you create or attach to the REPL. You might run a Clojure process with command line args to start a Socket REPL, and then use nc localhost <port> from within the :terminal. Or, maybe use lein figwheel ..., and drop directly to a ClojureScript REPL.

To accomplish this, I’ve dropped the augroup above which keeps track of the most recently opened :terminal job id globally, in favor of the following function.

" Returns the job id of the first terminal buffer on the
" current tab.
function! FirstTermOfTabJobId()
let t_id = nvim_get_current_tabpage()
for w_id in nvim_tabpage_list_wins(t_id)
let b_id = nvim_win_get_buf(w_id)
if nvim_buf_get_option(b_id, 'buftype') == 'terminal'
return nvim_buf_get_var(b_id, 'terminal_job_id')
endif
endfor
endfunction

REPLSend uses this new function, rather than the global job id.

function! REPLSend(cmd)
call jobsend(FirstTermOfTabJobId(), a:cmd."\n")
endfunction

That’s all there is to it. Any calls to <leader>ef or other mappings send code to the first :terminal buffer found on the current tab.