clj-suitable 0.8.0: Closing the Gap with Compliment
You had me at
js/.– Jerry Maguire, on ClojureScript interop completion
No sufficiently useful system can be both complete and consistent.
– Kurt Gödel, subtweeting every autocomplete ever
clj-suitable 0.8.0 is out! If the name doesn’t ring a bell, that’s rather the point - clj-suitable is the small library that quietly powers ClojureScript code completion in CIDER, Calva, and pretty much anything else that talks to a cljs REPL over nREPL. Think of it as the ClojureScript counterpart to what compliment1 does for Clojure. For years it lagged well behind its Clojure sibling, and this release is my attempt to finally close that gap - to make cljs completion, if you’ll forgive me, a touch more suitable.
This is also a direct follow-up to the Piggieback work I wrote about a couple of weeks ago. Once I had the cljs REPL plumbing back in decent shape, fixing up the completion story sitting on top of it was the obvious next move.2
The mission: catch up with compliment
Clojure programmers have had really good completion for years, thanks to compliment. ClojureScript programmers got a paler version of the same idea - the basics worked, but all the little touches that make completion feel smart were missing. My goal for this cycle was simple to state and less simple to deliver: teach clj-suitable the tricks compliment has had all along. I think we’re kind of there now.
Here’s what that means in practice, mostly borrowed straight from compliment’s playbook:
- Fuzzy matching. You no longer have to type a prefix -
csnow completes toclojure.stringandrkvtoreduce-kv, the same subsequence matching you’re used to on the Clojure side. - Smarter ranking. Candidates are ordered the way compliment orders them -
vars from the current namespace first, then
cljs.core, then everything else. The thing you actually want tends to be at the top instead of buried alphabetically. - Local bindings. Completion now sees the bindings from the surrounding form -
let,loop,fn,for,doseqand friends - including destructured ones. Typefirst|inside(let [{:keys [first-name]} m] ...)and you’ll getfirst-name, which previously you would not. - Referred vars. Inside a
(:require [clojure.string :refer [jo|]])clause you now getjoin, scoped to that one namespace rather than the whole world. - Context awareness. Special forms are only offered at the head of a list, so
if,letand company stop showing up as candidates in argument position where they make no sense.
None of these are revolutionary on their own, but together they’re the difference between completion that feels like an afterthought and completion that feels like it belongs.
A bit of backstory
There’s a nice irony in chasing compliment, because for a while ClojureScript completion actually lived inside it. Back in 2019 Andrea Richiardi ported the cljs-tooling completion machinery - the same code CIDER used for cljs at the time - straight into compliment, and there was even a follow-up attempt to pull clj-suitable’s JavaScript interop completions in alongside it. (You can still spot the heritage: a few functions in clj-suitable’s current source are marked “Ported from compliment.”)
In the end we went the other way around: instead of growing compliment to cover ClojureScript, we consolidated the ClojureScript side in clj-suitable and reverted the port. That sounds like wasted effort, but it wasn’t - compliment’s pluggable custom source architecture is exactly what made the split clean. clj-suitable just registers itself as another source, so tools get Clojure and ClojureScript completion side by side without compliment having to know a thing about cljs.
Robert Krahn had started clj-suitable earlier that year for the dynamic, runtime-introspection side, and the static ClojureScript completion found its permanent home there too. In hindsight it was clearly the right call: cljs completion gets to grow (and break, and get fixed) on its own schedule, and compliment stays focused and lean. A good architecture is the kind that makes the split you didn’t plan for feel obvious after the fact.
Why ClojureScript makes this harder
Completion for Clojure is almost unfairly simple. Your code runs on the same JVM as the nREPL server, so compliment can just reflect on the live thing - real vars, real namespaces, real Java classes, all sitting in the same process. Ask a question, get an answer.
ClojureScript doesn’t get to be that lucky, because it lives in two worlds at
once. The compiler is a Clojure program running on the JVM, and it’s the source
of truth for namespaces, vars and their metadata - so static completion reads
the ClojureScript compiler state, not your running program. But your actual
program runs somewhere else entirely: a Node process, a browser tab, maybe a
React Native app on a phone, reachable only across a REPL bridge. When you want
to complete JavaScript interop - the methods on js/console, say - there’s
nothing on the JVM to reflect on. You have to ship a bit of code across that
bridge, run it in the JS runtime, and read back what a live object actually
exposes.
That one fact is where all the complexity comes from. The bridge isn’t even a
single thing - a piggieback-driven cljs.repl runtime evaluates differently
from shadow-cljs, and clj-suitable has to speak both. The runtime can vanish
under you - refresh a browser tab and the namespace you loaded is gone. And
poking at a JS object to list its properties can have side effects, because a
property getter is just code that runs. (If you’ve ever wondered why
clj-suitable is so careful to only evaluate things that genuinely look like
interop, that’s why.)
So a completion request that looks like one operation from the editor is really two very different machines under the hood - one reading compiler state on the JVM, one evaluating code in a JS runtime you don’t control. Here’s the whole picture:
editor (CIDER / Calva)
| complete: prefix + context
v
nREPL server (one JVM) - cider-nrepl + clj-suitable
|
+-- static ---> compliment + clj-suitable's cljs source
| reads the ClojureScript compiler state
| (namespaces, vars, locals, keywords) - stays on the JVM
|
+-- dynamic --> only for JS interop forms
| eval introspection code across the REPL bridge
v
piggieback (cljs.repl) or shadow-cljs
|
v
Node / browser / React Native (the JS runtime)
suitable.js-introspection reads a live object's
properties and methods, and sends them back
|
v
candidates from both paths, merged and returned to the editor
Two paths, one answer. Clojure completion has only ever needed the top half of that diagram.
Dynamic completion, tightened up
That dynamic path is the fiddly one, and it’s where this release did most of
its sanding. The interop completion already worked - (.| js/console) would
offer you log, warn and the rest - but it had some rough edges:
- Completing interop no longer clobbers your REPL history. Poking at
(.| js/some-obj)used to quietly overwrite*1/*2/*3with the introspection result; now your last real value stays put where it belongs. - The introspection namespace is loaded once per session instead of on every single completion request. On a Node REPL that’s a needless round-trip gone from every keystroke.
- The browser-runtime path got hardened. I chased down a couple of long-standing “no completions in the browser” reports, stood up a real headless-Chrome integration test to reproduce them, and fixed a lurking crash along the way. (The short version of the investigation: the old failures came from an inlined build that current CIDER no longer produces, so most of you were never affected - but now there are tests making sure it stays that way.)
The full changelog has everything that didn’t make the highlights.
What came before
0.8.0 gets the headline, but it stands on the 0.7.0 release from a week
earlier, which did the unglamorous groundwork: modern dependencies
(ClojureScript 1.12, compliment 0.8.0, shadow-cljs 3.x), a move from CircleCI
to GitHub Actions, a tools.build-based build, and - crucially for my
sanity - actual integration tests that drive real Node and browser runtimes
instead of trusting things to work.
Coming to CIDER
If you use CIDER, you don’t have to do anything to get any of this - it’ll ship to you as part of CIDER 2.1. Calva and other nREPL-based tools that depend on clj-suitable will pick it up on their own schedule.
As always, this stands on the shoulders of others. Huge thanks to Alex Yakushev, whose compliment is both the benchmark I was chasing and the source of a good chunk of these ideas; to Andrea Richiardi, who did much of the early work bridging ClojureScript completion and compliment; and to Robert Krahn for creating clj-suitable in the first place and giving me such a solid foundation to build on.
Is any of this complete? Of course not - completeness is a horizon, not a destination, and of all people a completion library should be the first to admit it (see the gentleman up top). But clj-suitable suits ClojureScript a good deal better than it did a month ago, and that was rather the point.
Keep hacking!
-
The completion library, spelled with an i. Not the nice thing you say to someone, and - I really cannot stress this enough - not complement with an e. ↩
-
One thing invariably leads to another with this stuff. You set out to fix a REPL env wrapper and three weeks later you’re writing a headless-Chrome test harness. No regrets. ↩