<feed xmlns="http://www.w3.org/2005/Atom"> <id>https://batsov.com/</id><title>(think)</title><subtitle>Bozhidar Batsov's personal blog</subtitle> <updated>2026-11-06T10:47:14+02:00</updated> <author> <name>Bozhidar Batsov</name> <uri>https://batsov.com/</uri> </author><link rel="self" type="application/atom+xml" href="https://batsov.com/feed.xml"/><link rel="alternate" type="text/html" hreflang="en" href="https://batsov.com/"/> <generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator> <rights> © 2026 Bozhidar Batsov </rights> <icon>/assets/img/favicons/favicon.ico</icon> <logo>/assets/img/favicons/favicon-96x96.png</logo> <entry><title>Neocaml 0.11: OxCaml, Indentation Styles, and odoc</title><link href="https://batsov.com/articles/2026/11/05/neocaml-0-11-oxcaml-indentation-styles-and-odoc/" rel="alternate" type="text/html" title="Neocaml 0.11: OxCaml, Indentation Styles, and odoc" /><published>2026-11-05T07:30:00+02:00</published> <updated>2026-11-05T07:30:00+02:00</updated> <id>https://batsov.com/articles/2026/11/05/neocaml-0-11-oxcaml-indentation-styles-and-odoc/</id> <content type="html"><![CDATA[<p>I’m happy to announce that <a href="https://github.com/bbatsov/neocaml/releases/tag/v0.11.0">neocaml 0.11</a> is out! I never got around to writing about 0.10, which was mostly about <code class="language-plaintext highlighter-rouge">neocaml-utop</code> (a <a href="https://github.com/ocaml-community/utop"><code class="language-plaintext highlighter-rouge">utop</code></a> backend that talks to <code class="language-plaintext highlighter-rouge">utop</code> via its native editor protocol), but 0.11 is a different story. Its changelog is twice as long as that of any release before it, and by now I’ve stopped pretending that a grand 1.0 is just around the corner.<sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup> There’s a lot in there, but three things stand out for me - OxCaml support, indentation styles, and much better support for documentation. Here’s why…</p><h2 id="oxcaml">OxCaml</h2><p><a href="https://oxcaml.org/">OxCaml</a> is Jane Street’s set of extensions to OCaml - modes and modalities, kinds, unboxed types, stack allocation and so on. You can use them today with an OxCaml compiler switch, and Jane Street’s libraries use them all over the place. A lot of the heavy lifting happened upstream, as <a href="https://github.com/tree-sitter/tree-sitter-ocaml"><code class="language-plaintext highlighter-rouge">tree-sitter-ocaml</code></a> 0.26 learned to parse OxCaml, and <a href="https://github.com/tmcgilchrist">Tim McGilchrist</a> bumped neocaml’s grammars to it. <a href="https://github.com/314eter">Pieter Goetschalckx</a> (the <code class="language-plaintext highlighter-rouge">tree-sitter-ocaml</code> maintainer) once again cut an <a href="https://github.com/tree-sitter/tree-sitter-ocaml/tree/v0.26.0-abi14">ABI 14 build</a> of the release, so we didn’t have to break anyone’s Emacs. (see the <a href="/articles/2026/06/24/neocaml-0-9-repl-completion-and-robustness/">0.9 post</a> for the whole ABI saga) Huge thanks to both of them!</p><p>A grammar that parses the code is only half of the job, though. Here’s a bit of OxCaml, the way neocaml shows it:</p><p><img src="/assets/img/neocaml-oxcaml.png" alt="OxCaml code with kinds, unboxed types and literals, modes, stack allocation, an immutable array and a comprehension, highlighted by neocaml" /></p><p>neocaml now highlights and indents all of this. The new keywords (<code class="language-plaintext highlighter-rouge">stack_</code>, <code class="language-plaintext highlighter-rouge">exclave_</code>, <code class="language-plaintext highlighter-rouge">local_</code>, <code class="language-plaintext highlighter-rouge">global_</code>, <code class="language-plaintext highlighter-rouge">borrow_</code> and <code class="language-plaintext highlighter-rouge">kind_</code>), modes and modalities, kinds, unboxed types and literals (<code class="language-plaintext highlighter-rouge">float#</code>, <code class="language-plaintext highlighter-rouge">#1.0</code>, <code class="language-plaintext highlighter-rouge">#(...)</code>), immutable arrays (<code class="language-plaintext highlighter-rouge">[: ... :]</code>) and comprehensions are all covered. The indentation follows the way <a href="https://github.com/ocaml-ppx/ocamlformat"><code class="language-plaintext highlighter-rouge">ocamlformat</code></a> lays out Jane Street’s own OxCaml libraries (e.g. <code class="language-plaintext highlighter-rouge">portable</code>, <code class="language-plaintext highlighter-rouge">unique</code> and <code class="language-plaintext highlighter-rouge">parallel</code>), which is why the body of an <code class="language-plaintext highlighter-rouge">exclave_</code> isn’t indented, while those of <code class="language-plaintext highlighter-rouge">stack_</code> and <code class="language-plaintext highlighter-rouge">local_</code> are.<sup id="fnref:2"><a href="#fn:2" class="footnote" rel="footnote" role="doc-noteref">2</a></sup> Kind definitions also show up in imenu and work with the defun navigation commands.</p><p>If you don’t write OxCaml, nothing changes for you - plain OCaml looks exactly the way it did before. If you do, there’s nothing to turn on in neocaml. Just make sure Emacs sees your OxCaml switch and you’ll get its <a href="https://github.com/ocaml/ocaml-lsp"><code class="language-plaintext highlighter-rouge">ocamllsp</code></a> and <code class="language-plaintext highlighter-rouge">ocamlformat</code> as well. The <a href="https://neocaml.org/usage/#oxcaml">docs</a> have all the details.</p><h2 id="indentation-styles">Indentation styles</h2><p>Here’s a confession - neocaml’s indentation used to be a bit of a mix. Some layouts followed <code class="language-plaintext highlighter-rouge">ocamlformat</code>’s <code class="language-plaintext highlighter-rouge">conventional</code> profile, some followed <code class="language-plaintext highlighter-rouge">janestreet</code>, and a few followed neither. That’s a problem, as OCaml doesn’t have one indentation style everyone follows. <code class="language-plaintext highlighter-rouge">ocamlformat</code> ships several profiles, Jane Street’s code doesn’t look like the compiler’s, and plenty of projects never run a formatter at all.</p><p>So 0.11 lets you pick one of three styles via <code class="language-plaintext highlighter-rouge">neocaml-indent-style</code>:</p><ul><li><code class="language-plaintext highlighter-rouge">ocp-indent</code>, which indents the way <a href="https://github.com/OCamlPro/ocp-indent"><code class="language-plaintext highlighter-rouge">ocp-indent</code></a> does out of the box<li><code class="language-plaintext highlighter-rouge">conventional</code>, after <code class="language-plaintext highlighter-rouge">ocamlformat</code>’s default profile (used by ocaml.org, ppxlib and Irmin)<li><code class="language-plaintext highlighter-rouge">janestreet</code>, after <code class="language-plaintext highlighter-rouge">ocamlformat</code>’s <code class="language-plaintext highlighter-rouge">janestreet</code> profile (used by Jane Street’s libraries, <a href="https://dune.build/"><code class="language-plaintext highlighter-rouge">dune</code></a> and <code class="language-plaintext highlighter-rouge">ocaml-lsp</code>)</ul><p>They agree on most things and differ on a handful of layouts, e.g. how far the body of a match case goes:</p><div class="language-ocaml highlighter-rouge"><div class="highlight">class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
8
9
10
11
</pre><td class="rouge-code"><pre><span class="c">(* ocp-indent and janestreet *)</span>
<span class="k">match</span> <span class="n">value</span> <span class="k">with</span>
<span class="o">|</span> <span class="nc">Some</span> <span class="n">payload</span> <span class="o">-&amp;gt;</span>
  <span class="n">finish</span> <span class="n">payload</span>
<span class="o">|</span> <span class="nc">None</span> <span class="o">-&amp;gt;</span> <span class="mi">0</span>

<span class="c">(* conventional *)</span>
<span class="k">match</span> <span class="n">value</span> <span class="k">with</span>
<span class="o">|</span> <span class="nc">Some</span> <span class="n">payload</span> <span class="o">-&amp;gt;</span>
    <span class="n">finish</span> <span class="n">payload</span>
<span class="o">|</span> <span class="nc">None</span> <span class="o">-&amp;gt;</span> <span class="mi">0</span>
</pre></div></div><p>The best part is that most of the time you won’t have to pick a style yourself. By default neocaml looks for the project’s <code class="language-plaintext highlighter-rouge">.ocamlformat</code> and picks the style that matches its <code class="language-plaintext highlighter-rouge">profile</code> (it also respects <code class="language-plaintext highlighter-rouge">cases-exp-indent</code>). Without an <code class="language-plaintext highlighter-rouge">.ocamlformat</code> it checks for an <code class="language-plaintext highlighter-rouge">.ocp-indent</code> file, and without either you get the <code class="language-plaintext highlighter-rouge">ocp-indent</code> style, as that’s what most projects that don’t use <code class="language-plaintext highlighter-rouge">ocamlformat</code> indent with. (e.g. <a href="https://opam.ocaml.org/"><code class="language-plaintext highlighter-rouge">opam</code></a>, Rocq, Frama-C, Eio and the compiler itself) You can also set the style per project in <code class="language-plaintext highlighter-rouge">.dir-locals.el</code>, or switch it for the current buffer with <code class="language-plaintext highlighter-rouge">M-x neocaml-set-indent-style</code>.</p><p>I also wanted to know how well all of this works on real code, instead of guessing. So I wrote a couple of small scripts (they live in <code class="language-plaintext highlighter-rouge">dev/</code>) that format a bunch of OCaml files with each <code class="language-plaintext highlighter-rouge">ocamlformat</code> profile and count the lines neocaml would indent differently. I ran them on the almost 300 files of the <code class="language-plaintext highlighter-rouge">opam</code> packages in my default switch (mostly Jane Street’s <code class="language-plaintext highlighter-rouge">base</code>), and 0.10 didn’t look great. About one line in ten had no indentation rule at all, so <code class="language-plaintext highlighter-rouge">TAB</code> simply left it wherever you typed it, and only 81.9% of the lines landed where <code class="language-plaintext highlighter-rouge">ocamlformat</code>’s <code class="language-plaintext highlighter-rouge">conventional</code> profile puts them (77.8% for <code class="language-plaintext highlighter-rouge">janestreet</code>). Ouch! With the matching style, 0.11 gets 97.5% and 98% of them right.<sup id="fnref:3"><a href="#fn:3" class="footnote" rel="footnote" role="doc-noteref">3</a></sup> Not perfect, but pretty close!</p><p>Keep in mind that indentation only places the lines you’ve broken, it doesn’t break them for you. If you run <code class="language-plaintext highlighter-rouge">ocamlformat</code> on save it has the final word anyway, but with the matching style less of what you type moves around when you save.</p><h2 id="odoc-and-doc-comments">odoc and doc comments</h2><p>The third big theme is documentation. A few months ago <a href="https://github.com/mlemerre">Matthieu Lemerre</a> suggested (<a href="https://github.com/bbatsov/neocaml/issues/60">#60</a>) that neocaml should render the <code class="language-plaintext highlighter-rouge">ocamldoc</code> markup in comments, instead of showing it as a soup of braces. I loved the idea, and in 0.11 doc comments finally look like documentation - <code class="language-plaintext highlighter-rouge">{b bold}</code> is bold, <code class="language-plaintext highlighter-rouge">{i italic}</code> is italic, <code class="language-plaintext highlighter-rouge">[code]</code>, <code class="language-plaintext highlighter-rouge">{[ ... ]}</code> and <code class="language-plaintext highlighter-rouge">{@ocaml[ ... ]}</code> are shown as code, and <code class="language-plaintext highlighter-rouge">{!references}</code> and tags like <code class="language-plaintext highlighter-rouge">@param</code> get faces of their own. This is the new <code class="language-plaintext highlighter-rouge">doc-markup</code> font-lock feature, and it’s enabled at the default font-lock level. Here’s how the doc comments in an interface file look now:</p><p><img src="/assets/img/neocaml-doc-comments.png" alt="Doc comments in an OCaml interface file, with bold and italic text, inline code, a code block, references and @param tags rendered by neocaml" /></p><p>Tim also contributed <code class="language-plaintext highlighter-rouge">neocaml-odoc-mode</code> (<a href="https://github.com/bbatsov/neocaml/pull/46">#46</a>) for <a href="https://github.com/ocaml/odoc"><code class="language-plaintext highlighter-rouge">odoc</code></a>’s standalone <code class="language-plaintext highlighter-rouge">.mld</code> documentation pages, built on Tim’s own <a href="https://github.com/tmcgilchrist/tree-sitter-odoc">Tree-sitter grammar for <code class="language-plaintext highlighter-rouge">odoc</code></a>. You get font-lock for headings, inline markup, references, links, tags, lists and tables, plus indentation and imenu. On Emacs 30+ the code in <code class="language-plaintext highlighter-rouge">{@ocaml[...]}</code>, <code class="language-plaintext highlighter-rouge">{@dune[...]}</code> and <code class="language-plaintext highlighter-rouge">{@opam[...]}</code> blocks is highlighted as well, using the grammars for those languages:</p><p><img src="/assets/img/neocaml-odoc-mode.png" alt="An odoc .mld page in neocaml-odoc-mode, with headings, inline markup, references and highlighted dune and OCaml code blocks" /></p><p>Run <code class="language-plaintext highlighter-rouge">M-x neocaml-odoc-install-grammar</code> to get started, and check out the <a href="https://neocaml.org/odoc/">docs</a> for more.</p><p>While I was at it, I also fixed <code class="language-plaintext highlighter-rouge">M-q</code> and <code class="language-plaintext highlighter-rouge">M-j</code> in comments. Embarrassingly, <code class="language-plaintext highlighter-rouge">M-q</code> used to always fill the first paragraph of a comment, no matter where you were in it, and it merged code blocks into the text around them. <code class="language-plaintext highlighter-rouge">M-j</code> now continues the <code class="language-plaintext highlighter-rouge">*</code> of comments that start each line with one.</p><h2 id="grab-bag">Grab Bag</h2><p>Those are just the highlights, of course. Here are a few more things that I think you’ll like:</p><ul><li><code class="language-plaintext highlighter-rouge">neocaml-mlx-mode</code> for <code class="language-plaintext highlighter-rouge">.mlx</code> files (<a href="https://github.com/ocaml-mlx/mlx">OCaml with JSX</a>, as used by <a href="https://melange.re/">Melange</a> and React), contributed by <a href="https://github.com/akirak">Akira Komamura</a><li>commands for running the tests of the current file and dealing with <code class="language-plaintext highlighter-rouge">dune</code>’s promotions (<code class="language-plaintext highlighter-rouge">C-c C-o T</code>, <code class="language-plaintext highlighter-rouge">C-c C-o =</code> and <code class="language-plaintext highlighter-rouge">C-c C-o P</code>), with their own bindings in <code class="language-plaintext highlighter-rouge">neocaml-cram-mode</code><li>the toplevel’s responses in the REPL are now highlighted with Tree-sitter, just like your code<li>each <code class="language-plaintext highlighter-rouge">dune</code> project in a monorepo gets its own REPL<li><code class="language-plaintext highlighter-rouge">neocaml-format-buffer</code> replaces only the text that changed, so point and the window positions stay put</ul><p>Here’s the REPL highlighting in action:</p><p><img src="/assets/img/neocaml-repl-output.png" alt="An OCaml file and neocaml's REPL below it, with the toplevel's responses highlighted like regular OCaml code" /></p><h2 id="upgrading">Upgrading</h2><p>Most setups will keep working just fine, but a few changes in this release require some action on your part:</p><ul><li>neocaml needs <code class="language-plaintext highlighter-rouge">tree-sitter-ocaml</code> 0.26 now, so reinstall the OCaml grammars with <code class="language-plaintext highlighter-rouge">C-u M-x neocaml-install-grammars</code>. The <code class="language-plaintext highlighter-rouge">dune</code> grammar moved to its new home, <a href="https://github.com/ocaml-dune/tree-sitter-dune"><code class="language-plaintext highlighter-rouge">ocaml-dune/tree-sitter-dune</code></a>, so reinstall it as well with <code class="language-plaintext highlighter-rouge">C-u M-x neocaml-dune-install-grammar</code>. neocaml will warn you if your grammars are too old.<li>Some keybindings clashed with <a href="https://github.com/tarides/ocaml-eglot"><code class="language-plaintext highlighter-rouge">ocaml-eglot</code></a> and <a href="https://github.com/ocaml/merlin"><code class="language-plaintext highlighter-rouge">merlin</code></a>, so they had to move. In the REPL minor modes sending the phrase at point is now <code class="language-plaintext highlighter-rouge">C-c C-e</code>, sending it and moving to the next one <code class="language-plaintext highlighter-rouge">C-c C-s</code>, loading the file <code class="language-plaintext highlighter-rouge">C-c C-u</code> and interrupting the toplevel <code class="language-plaintext highlighter-rouge">C-c C-q</code>. The <code class="language-plaintext highlighter-rouge">dune</code> commands moved from <code class="language-plaintext highlighter-rouge">C-c C-d</code> to <code class="language-plaintext highlighter-rouge">C-c C-o</code>.<li>The default indentation changed. If your project uses neither <code class="language-plaintext highlighter-rouge">ocamlformat</code> nor <code class="language-plaintext highlighter-rouge">ocp-indent</code> a few layouts will move, so set <code class="language-plaintext highlighter-rouge">neocaml-indent-style</code> if the <code class="language-plaintext highlighter-rouge">ocp-indent</code> style is not your cup of tea.</ul><h2 id="epilogue">Epilogue</h2><p>With OxCaml, mlx and <code class="language-plaintext highlighter-rouge">odoc</code> on board, neocaml now covers pretty much every kind of OCaml file I can think of. Famous last words, right?</p><p>So, what’s next? I don’t really have any big plans for 0.12. I’d like to switch <code class="language-plaintext highlighter-rouge">neocaml-mlx-mode</code> to the dedicated grammar from <a href="https://github.com/ocaml-mlx/tree-sitter-mlx"><code class="language-plaintext highlighter-rouge">tree-sitter-mlx</code></a> once it has an ABI 14 release, and there’s the usual pile of smaller ideas in the <a href="https://github.com/bbatsov/neocaml/blob/main/ROADMAP.md">roadmap</a>. No promises, though.</p><p>Big thanks to <a href="https://github.com/tmcgilchrist">Tim McGilchrist</a>, who did a ton of work on the grammars (OCaml, <code class="language-plaintext highlighter-rouge">dune</code>, <code class="language-plaintext highlighter-rouge">opam</code> and <a href="https://gallium.inria.fr/~fpottier/menhir/"><code class="language-plaintext highlighter-rouge">menhir</code></a>) and the <code class="language-plaintext highlighter-rouge">odoc</code> support! Thanks to <a href="https://github.com/akirak">Akira Komamura</a> for bringing mlx to neocaml, and to <a href="https://github.com/dalz">Alessio Duè</a> for fixing a sexp navigation bug! And thanks to everyone who reported problems and shared ideas - many of the improvements in 0.11 started as an issue on GitHub.</p><p>As usual - update from <a href="https://melpa.org/#/neocaml">MELPA</a>, play with it, and let me know how it goes. The full list of changes is in the <a href="https://github.com/bbatsov/neocaml/releases/tag/v0.11.0">release notes</a>.</p><p>That’s all I have for you today. Keep hacking!</p><div class="footnotes" role="doc-endnotes"><ol><li id="fn:1"><p>See the <a href="/articles/2026/03/25/neocaml-0-6-opam-dune-and-more/">0.6 post</a> for how that went the last time. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&amp;#8617;&amp;#xfe0e;</a></p><li id="fn:2"><p>That’s how <code class="language-plaintext highlighter-rouge">ocamlformat</code> formats Jane Street’s code, and I’ve learned to trust it more than my own intuition here. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&amp;#8617;&amp;#xfe0e;</a></p><li id="fn:3"><p>That’s about 77,000 lines formatted with <code class="language-plaintext highlighter-rouge">ocamlformat</code> 0.27.0. The numbers depend on the code and the ocamlformat version, so take them with a grain of salt. <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&amp;#8617;&amp;#xfe0e;</a></p></ol></div>]]></content> <author> <name>bbatsov</name> </author> <summary>I’m happy to announce that neocaml 0.11 is out! I never got around to writing about 0.10, which was mostly about neocaml-utop (a utop backend that talks to utop via its native editor protocol), but 0.11 is a different story. Its changelog is twice as long as that of any release before it, and by now I’ve stopped pretending that a grand 1.0 is just around the corner.1 There’s a lot in there, but...</summary> </entry> <entry><title>Neocaml 0.9: A Better REPL, Dune/Opam Completion, and More Robustness</title><link href="https://batsov.com/articles/2026/06/24/neocaml-0-9-repl-completion-and-robustness/" rel="alternate" type="text/html" title="Neocaml 0.9: A Better REPL, Dune/Opam Completion, and More Robustness" /><published>2026-06-24T10:00:00+03:00</published> <updated>2026-06-24T10:00:00+03:00</updated> <id>https://batsov.com/articles/2026/06/24/neocaml-0-9-repl-completion-and-robustness/</id> <content type="html"><![CDATA[<p>It’s been a couple of months since the last <a href="https://github.com/bbatsov/neocaml">neocaml</a> release, and the reason is simple — for a while there I was genuinely out of ideas. Back when I shipped <a href="/articles/2026/03/25/neocaml-0-6-opam-dune-and-more/">0.6</a> I declared (again!) that I was done with new features, and this time I almost meant it. But ideas have a way of creeping back in, and 0.9 turned out to be a meaty release. Here are the highlights.</p><h2 id="a-much-nicer-repl-experience">A much nicer REPL experience</h2><p>The biggest chunk of work went into the REPL (toplevel) integration. I’m well aware that the OCaml toplevel isn’t terribly popular with seasoned OCaml developers — most of them reach for a proper build and a debugger instead. But I think newcomers get a lot of mileage out of a REPL, and (no surprise to anyone who’s followed my work) I’m a Lisper at heart with a real soft spot for interactive development. Clojure and Emacs Lisp spoiled me, and I want OCaml beginners to taste a bit of that too.</p><p>So, what’s new:</p><ul><li><strong>A dedicated REPL per project.</strong> The REPL buffer is now named after its project (e.g. <code class="language-plaintext highlighter-rouge">*OCaml: myproject*</code>), and the send commands route to the current buffer’s project REPL. You can have several projects running side by side without them stepping on each other.<li><p><strong>Choose your toplevel.</strong> The new <code class="language-plaintext highlighter-rouge">neocaml-repl-flavor</code> lets you pick between <code class="language-plaintext highlighter-rouge">ocaml</code>, <code class="language-plaintext highlighter-rouge">utop</code>, and <code class="language-plaintext highlighter-rouge">dune-utop</code>. Set it globally, or per project via <code class="language-plaintext highlighter-rouge">.dir-locals.el</code>:</p><div class="language-emacs-lisp highlighter-rouge"><div class="highlight">class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre><td class="rouge-code"><pre><span class="p">((</span><span class="nv">neocaml-mode</span> <span class="o">.</span> <span class="p">((</span><span class="nv">neocaml-repl-flavor</span> <span class="o">.</span> <span class="nv">dune-utop</span><span class="p">))))</span>
</pre></div></div><p>The active flavor shows up in the REPL’s mode line, so you always know what you’re talking to.</p><li><strong>Send a phrase and step.</strong> <code class="language-plaintext highlighter-rouge">C-c C-n</code> (<code class="language-plaintext highlighter-rouge">neocaml-repl-send-phrase-and-step</code>) sends the phrase at point to the REPL and moves on to the next one — perfect for walking through a file top to bottom while you experiment.<li><strong><code class="language-plaintext highlighter-rouge">#require</code> from Emacs.</strong> <code class="language-plaintext highlighter-rouge">neocaml-repl-require</code> loads a <code class="language-plaintext highlighter-rouge">findlib</code> package into the running toplevel without you having to type the directive by hand.<li><strong>Restart on demand.</strong> <code class="language-plaintext highlighter-rouge">neocaml-repl-restart</code> kills and restarts the toplevel when things get into a weird state.</ul><h2 id="completion-for-opam-and-dune-no-lsp-required">Completion for Opam and Dune, no LSP required</h2><p>This one I’m particularly happy with. <code class="language-plaintext highlighter-rouge">.ml</code>/<code class="language-plaintext highlighter-rouge">.mli</code> files have <code class="language-plaintext highlighter-rouge">ocaml-lsp-server</code> to lean on for completion, but the auxiliary file formats have no language server at all. That’s exactly the kind of gap neocaml is meant to fill, so both <code class="language-plaintext highlighter-rouge">neocaml-dune-mode</code> and <code class="language-plaintext highlighter-rouge">neocaml-opam-mode</code> now ship a <code class="language-plaintext highlighter-rouge">completion-at-point</code> backend.</p><p>In a <code class="language-plaintext highlighter-rouge">dune</code> file you get completion for stanza names, the field names valid for the <em>enclosing</em> stanza, and library names inside <code class="language-plaintext highlighter-rouge">libraries</code>/<code class="language-plaintext highlighter-rouge">pps</code> fields:</p><pre><code class="language-dune">(library
 (name my_lib)
 (libraries str re|))   ; &amp;lt;- completes both your own libraries and installed findlib ones
</code></pre><p>The library candidates combine your project’s own libraries with whatever’s installed in the active Opam switch. And it’s switch-aware — if there’s a project-local switch (an <code class="language-plaintext highlighter-rouge">_opam/</code> directory), neocaml detects it and queries it via <code class="language-plaintext highlighter-rouge">opam exec --</code>, without any configuration on your part. The results are cached per project, so it stays snappy.</p><p>In <code class="language-plaintext highlighter-rouge">opam</code> files you get completion for field and section names, and package names inside <code class="language-plaintext highlighter-rouge">depends</code>/<code class="language-plaintext highlighter-rouge">depopts</code>/<code class="language-plaintext highlighter-rouge">conflicts</code> (sourced from <code class="language-plaintext highlighter-rouge">opam list</code>):</p><pre><code class="language-opam">depends: [
  "dune" {&amp;gt;= "3.0"}
  "cmd|"   ; &amp;lt;- completes to cmdliner and friends
]
</code></pre><p>Both backends can be toggled off via <code class="language-plaintext highlighter-rouge">neocaml-dune-complete-libraries</code> and <code class="language-plaintext highlighter-rouge">neocaml-opam-complete-packages</code> if you’d rather not have them.</p><h2 id="robustness-improvements">Robustness improvements</h2><p>A good chunk of this release is the unglamorous but important work of making things just behave correctly:</p><ul><li><strong>Character literals and quoted strings at the syntactic layer.</strong> Tree-sitter fontifies <code class="language-plaintext highlighter-rouge">'"'</code>, <code class="language-plaintext highlighter-rouge">'('</code>, and <code class="language-plaintext highlighter-rouge">{|raw "strings"|}</code> correctly, but the syntax table underneath was getting confused — which broke <code class="language-plaintext highlighter-rouge">forward-sexp</code>, <code class="language-plaintext highlighter-rouge">delete-pair</code>, and <code class="language-plaintext highlighter-rouge">electric-pair-mode</code> around those constructs. The fix was a good old <code class="language-plaintext highlighter-rouge">syntax-propertize-function</code>. I wrote up the whole story over on Emacs Redux in <a href="https://emacsredux.com/blog/2026/06/22/tree-sitter-modes-still-need-a-syntax-table/">Tree-sitter Modes Still Need a Syntax Table</a>, if you’re into mode-writing internals.<li><strong><code class="language-plaintext highlighter-rouge">project.el</code> integration.</strong> A directory with a <code class="language-plaintext highlighter-rouge">dune-project</code> file is now recognized as a project root (even without version control), <code class="language-plaintext highlighter-rouge">_build/</code> and <code class="language-plaintext highlighter-rouge">_opam/</code> are ignored, and <code class="language-plaintext highlighter-rouge">compile-command</code> defaults to <code class="language-plaintext highlighter-rouge">dune build</code> in dune projects.</ul><p>There’s more in there too — <code class="language-plaintext highlighter-rouge">ocamlformat</code> integration (<code class="language-plaintext highlighter-rouge">C-c C-f</code>), clickable URLs and bug references in comments, a font-lock level selector, and richer menus across the modes.</p><h2 id="ocaml-55-support-and-the-abi-14-balancing-act">OCaml 5.5 support, and the ABI 14 balancing act</h2><p><a href="https://discuss.ocaml.org/t/ocaml-5-5-0-released/18265">OCaml 5.5 was released</a> on June 19th, so this felt like a good moment to ship 5.5 support in neocaml. The <code class="language-plaintext highlighter-rouge">ocaml</code> and <code class="language-plaintext highlighter-rouge">ocaml-interface</code> grammars now track tree-sitter-ocaml v0.25.0, which brings the 5.5 grammar along with it.</p><p>There’s a wrinkle here that’s worth explaining, because it’s shaped the last few releases. A tree-sitter grammar gets compiled into a parser that speaks a particular <strong>ABI version</strong>, and Emacs can only load parsers up to the latest ABI supported by the <code class="language-plaintext highlighter-rouge">libtree-sitter</code> it was <em>built against</em> — it’s not the Emacs version itself that sets the ceiling. In practice a lot of Emacs 30 builds out there (notably Homebrew’s on macOS) are linked against tree-sitter 0.24, which tops out at <strong>ABI 14</strong>; you need an Emacs built against tree-sitter 0.25+ to load <strong>ABI 15</strong> grammars. The trouble is that the tree-sitter 0.25 CLI now generates ABI 15 parsers by default, so any grammar regenerated with current tooling produces something those builds simply can’t load — you install it and it just errors out. Emacs 31 will ship with newer tree-sitter and make ABI 15 the common case, but it’s not out yet. (This isn’t a neocaml problem as such; it’s been biting tree-sitter modes across the ecosystem.)</p><p>After a few users ran into exactly this, I’ve made a deliberate decision: <strong>stick to ABI 14 grammars until Emacs 31 is widely available.</strong> That effort started a couple of releases back — in 0.8.1 I lowered the ABI requirement from 15 to 14 across the opam, dune, and ocamllex modes, switched the menhir recipe to <a href="https://github.com/tmcgilchrist/tree-sitter-menhir">tmcgilchrist/tree-sitter-menhir</a>, and pinned ocamllex back to v0.24.0, all of which target ABI 14 (<a href="https://github.com/bbatsov/neocaml/issues/42">#42</a>). 0.9 extends that policy to the core OCaml grammars.</p><p>The catch with v0.25.0 is precisely that it generates an ABI 15 parser. Happily, the 5.5 grammar didn’t actually need any ABI 15 features — the bump rode along with the CLI upgrade — so an ABI 14 regeneration of the very same grammar is a drop-in. Big thanks to <a href="https://github.com/314eter">314eter</a>, the <code class="language-plaintext highlighter-rouge">tree-sitter-ocaml</code> maintainer, for cutting a <code class="language-plaintext highlighter-rouge">v0.25.0-abi14</code> tag for exactly this purpose (<a href="https://github.com/tree-sitter/tree-sitter-ocaml/issues/141">#141</a>). The one snag was that tagging normally triggers releases to NPM, crates.io, and PyPI, so I sent a small PR to skip publishing for ABI-suffixed tags (<a href="https://github.com/tree-sitter/tree-sitter-ocaml/pull/142">#142</a>), and the tag followed. neocaml now pins both grammars to it.</p><h2 id="roadmap-and-docs">Roadmap and docs</h2><p>If you’re curious where neocaml is headed, I’ve started keeping a <a href="https://github.com/bbatsov/neocaml/blob/main/ROADMAP.md">ROADMAP.md</a> with ideas and guiding principles (short version: tree-sitter first, lean on the LSP stack for <code class="language-plaintext highlighter-rouge">.ml</code>/<code class="language-plaintext highlighter-rouge">.mli</code>, and own the auxiliary modes that have no language server). The project also has a proper documentation site now at <a href="https://neocaml.org">neocaml.org</a>, so there’s a real home for the details beyond the README.</p><h2 id="give-it-a-try">Give it a Try</h2><p>As always — update from <a href="https://melpa.org/#/neocaml">MELPA</a>, play with it, and let me know how it goes. The full list of changes is in the <a href="https://github.com/bbatsov/neocaml/releases/tag/v0.9.0">0.9.0 release notes</a>. Bug reports, feature requests, and pull requests are all welcome on <a href="https://github.com/bbatsov/neocaml">GitHub</a>.</p><p>That’s all from me, folks! Keep hacking!</p>]]></content> <author> <name>bbatsov</name> </author> <summary>It’s been a couple of months since the last neocaml release, and the reason is simple — for a while there I was genuinely out of ideas. Back when I shipped 0.6 I declared (again!) that I was done with new features, and this time I almost meant it. But ideas have a way of creeping back in, and 0.9 turned out to be a meaty release. Here are the highlights. A much nicer REPL experience The bigge...</summary> </entry> <entry><title>Emacs loves AsciiDoc</title><link href="https://batsov.com/articles/2026/06/11/emacs-loves-asciidoc/" rel="alternate" type="text/html" title="Emacs loves AsciiDoc" /><published>2026-06-11T09:00:00+03:00</published> <updated>2026-06-11T09:00:00+03:00</updated> <id>https://batsov.com/articles/2026/06/11/emacs-loves-asciidoc/</id> <content type="html"><![CDATA[<p>Regular readers know I have a soft spot for <a href="https://asciidoc.org/">AsciiDoc</a> – I’ve written about it more than once, and it’s the markup behind the documentation of most of my bigger OSS projects (CIDER, nREPL, Projectile, RuboCop). It hits a sweet spot Markdown never quite reaches: rich enough for proper technical writing (admonitions, includes, real tables, cross-references), without dragging in the full ceremony of something like DocBook.</p><p>There was just one problem, and it nagged at me for years: editing AsciiDoc in Emacs was never much fun.</p><h2 id="background">Background</h2><p>For years the only real option was <code class="language-plaintext highlighter-rouge">adoc-mode</code>, and it was showing its age. The font-locking was uneven, it predated modern Asciidoctor syntax, and it lagged far behind what <code class="language-plaintext highlighter-rouge">markdown-mode</code> (and <code class="language-plaintext highlighter-rouge">org-mode</code>) offered. So I’d catch myself reaching for Markdown in situations where AsciiDoc was clearly the better tool – not because Markdown was better, but because <code class="language-plaintext highlighter-rouge">markdown-mode</code> was. That always bugged me.</p><p>Eventually the itch got bad enough that I did something about it. Back in 2023 I <a href="https://metaredux.com/posts/2023/03/12/adoc-mode-reborn.html">took over maintenance of adoc-mode</a>, which had been dormant for years, and set myself a clear goal: get it to feature parity with <code class="language-plaintext highlighter-rouge">markdown-mode</code>. More recently I also started a brand-new <a href="https://github.com/bbatsov/asciidoc-mode">asciidoc-mode</a> built on top of tree-sitter. The target for both is the same – AsciiDoc support in Emacs that’s on par with Markdown, and ideally not far off from Org.</p><h2 id="less-is-more-unless-its-modes">Less is more, unless it’s modes</h2><p>Why two modes? They scratch slightly different itches:</p><ul><li><code class="language-plaintext highlighter-rouge">adoc-mode</code> is the classic, regexp-based mode. It works on any reasonably modern Emacs, has no external dependencies, and now ships a pile of interactive features (markup commands, list editing, image preview, tempo templates).<li><code class="language-plaintext highlighter-rouge">asciidoc-mode</code> is the newer, tree-sitter-based mode. It leans on a real grammar for robust, accurate highlighting and good performance in large documents. It’s leaner by design, and it needs Emacs 30.1+ with tree-sitter.<sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup></ul><p>If you want the kitchen sink on any Emacs, reach for <code class="language-plaintext highlighter-rouge">adoc-mode</code>. If you’re on a recent Emacs and want highlighting backed by an actual parser, try <code class="language-plaintext highlighter-rouge">asciidoc-mode</code>. Pick whichever fits your setup – both are actively maintained.</p><h2 id="recent-developments">Recent developments</h2><p>I’ve spent a good chunk of the past month bringing both modes to a place I’m genuinely happy with, and they’re now mostly feature-complete as far as I’m concerned. Some highlights:</p><ul><li><strong>Modern AsciiDoc, not legacy AsciiDoc.py.</strong> Both modes now track the modern Asciidoctor spec – something I’d <a href="/articles/2024/02/22/asciidoc-language-specification/">wanted to do for a while</a>. <code class="language-plaintext highlighter-rouge">+text+</code> and <code class="language-plaintext highlighter-rouge">++text++</code> are treated as passthroughs rather than monospace, curved-quote syntax (<code class="language-plaintext highlighter-rouge">"`text`"</code>) is recognized, the block ID shorthand <code class="language-plaintext highlighter-rouge">[#id]</code> is supported, checklists are highlighted, and the menus and templates dropped a bunch of deprecated AsciiDoc.py leftovers.<li><strong>More uniform font-locking.</strong> I went through the faces in both modes and aligned them with <code class="language-plaintext highlighter-rouge">markdown-mode</code> and <code class="language-plaintext highlighter-rouge">org-mode</code>: bold renders as plain bold, emphasis as plain italic, structural markup (delimiters, list markers) fades into the background, and dedicated semantic faces cover metadata, URLs, footnotes, highlights, and roles. Switching between Markdown and AsciiDoc no longer feels jarring.<li><strong>Navigation that actually works.</strong> Clickable cross-references and links, an <code class="language-plaintext highlighter-rouge">xref</code> backend over anchors, context-aware completion, and – for <code class="language-plaintext highlighter-rouge">adoc-mode</code> – cross-file Antora cross-reference resolution.<li><strong>Tooling integration.</strong> Asciidoctor-backed preview/export and a Flymake checker that reports parser errors inline.<li><strong>Native code-block fontification.</strong> <code class="language-plaintext highlighter-rouge">[source,LANG]</code> blocks in <code class="language-plaintext highlighter-rouge">asciidoc-mode</code> are highlighted using the language’s own major mode, just like in <code class="language-plaintext highlighter-rouge">markdown-mode</code> and <code class="language-plaintext highlighter-rouge">org-mode</code>. <code class="language-plaintext highlighter-rouge">adoc-mode</code> had added support for this a while back.</ul><p>Getting <code class="language-plaintext highlighter-rouge">asciidoc-mode</code> where I wanted it also meant sending a few fixes upstream to the <a href="https://github.com/cathaysia/tree-sitter-asciidoc">tree-sitter-asciidoc</a> grammar it builds on. A grammar-backed mode is only as good as its grammar, so some of the work happened a layer below Emacs. If you’re curious about that side of things, I wrote up the broader experience in <a href="/articles/2026/02/27/building-emacs-major-modes-with-treesitter-lessons-learned/">building Emacs major modes with Tree-sitter</a>.</p><h2 id="give-it-a-try">Give it a try</h2><p>If you’ve been avoiding AsciiDoc in Emacs because the tooling wasn’t there – I get it, that was me too. But the situation is genuinely different now. Grab <a href="https://github.com/bbatsov/adoc-mode">adoc-mode</a> or <a href="https://github.com/bbatsov/asciidoc-mode">asciidoc-mode</a>, point it at one of your <code class="language-plaintext highlighter-rouge">.adoc</code> files, and see how it feels.</p><p>And please share your feedback. Both projects are at the stage where real-world usage surfaces the rough edges I can’t see myself. Bug reports, ideas, and PRs are all very welcome on the respective issue trackers.</p><p>Together we can make AsciiDoc a first-class citizen of the Emacs ecosystem!<sup id="fnref:2"><a href="#fn:2" class="footnote" rel="footnote" role="doc-noteref">2</a></sup></p><p>That’s all from me, folks! Keep hacking!</p><div class="footnotes" role="doc-endnotes"><ol><li id="fn:1"><p>Also, I really wanted to name something <code class="language-plaintext highlighter-rouge">asciidoc-mode</code>. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&amp;#8617;&amp;#xfe0e;</a></p><li id="fn:2"><p>Afterwards we’ll aim for world domination, of course. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&amp;#8617;&amp;#xfe0e;</a></p></ol></div>]]></content> <author> <name>bbatsov</name> </author> <summary>Regular readers know I have a soft spot for AsciiDoc – I’ve written about it more than once, and it’s the markup behind the documentation of most of my bigger OSS projects (CIDER, nREPL, Projectile, RuboCop). It hits a sweet spot Markdown never quite reaches: rich enough for proper technical writing (admonitions, includes, real tables, cross-references), without dragging in the full ceremony of...</summary> </entry> <entry><title>nREPL Forever</title><link href="https://batsov.com/articles/2026/05/20/nrepl-forever/" rel="alternate" type="text/html" title="nREPL Forever" /><published>2026-05-20T09:00:00+03:00</published> <updated>2026-05-20T09:00:00+03:00</updated> <id>https://batsov.com/articles/2026/05/20/nrepl-forever/</id> <content type="html"><![CDATA[<p>Last week I <a href="/articles/2026/05/12/port-a-minimalist-prepl-client-for-emacs/">announced Port</a>, a small prepl client for Emacs. That post focused on Port itself, but writing it left me with the itch to do a follow-up on the bigger picture, because the socket REPL / prepl story is one I’ve been meaning to write up for years.</p><p>If you’ve been around Clojure long enough, you remember the chatter. Socket REPL landed in Clojure 1.8 (January 2016), prepl in Clojure 1.10 (December 2018), and for a couple of years there was a steady stream of posts, tweets, and Slack threads to the effect of “this is what we should be building tools on. nREPL is on the way out.” Some serious people put their weight behind that idea, and some of them went and built tools to prove it.</p><p>Now it’s 2026 and we can take stock.</p><h2 id="sky-is-the-limit">Sky is the limit</h2><p>The pitch was good. Socket REPL is just <em>the</em> Clojure REPL exposed on a TCP port. prepl wraps it with a structured printer so the bytes coming back are EDN-tagged maps (<code class="language-plaintext highlighter-rouge">:ret</code>, <code class="language-plaintext highlighter-rouge">:out</code>, <code class="language-plaintext highlighter-rouge">:err</code>, <code class="language-plaintext highlighter-rouge">:tap</code>) instead of a human-readable prompt. Both ship with Clojure itself. No external server library, no middleware, no third-party namespaces. You start a JVM, you bind a port, you’re done.</p><p>The intellectual case for moving off nREPL had been made by Rich Hickey himself, most clearly in a <a href="https://groups.google.com/g/clojure-dev/c/Dl3Stw5iRVA/m/IHoVWiJz5UIJ">March 2015 clojure-dev post</a> that’s worth reading in full. Rich didn’t actually attack nREPL by name in that message. What he did was argue carefully for what a REPL <em>is</em>: a thing that reads characters, evaluates forms, prints results, and loops, with those streams available to user code so that things like nested REPLs and debuggers compose naturally. The money line:</p><blockquote><p>While framing and RPC orientation might make things easier for someone who just wants to implement an eval window, it makes the resulting service strictly less powerful than a REPL.</p></blockquote><p>His proposal, in the same post, was that tools should open <em>multiple</em> connections to the running program: one for the human-facing stream, and dedicated channels for IDE operations. The socket REPL (which landed in 1.8 the following January) and prepl (which arrived in 1.10) were the official implementation of that worldview.</p><p>A handful of editor projects took the cue and built clients:</p><ul><li><a href="https://tutkain.flowthing.me/">Tutkain</a> for Sublime Text<li><a href="https://github.com/mauricioszabo/atom-chlorine">Chlorine</a> for Atom<li><a href="https://github.com/Olical/conjure">Conjure</a> for Neovim (in its early Rust incarnation)<li><a href="https://github.com/tonsky/Clojure-Sublimed">Clojure-Sublimed</a> by Nikita Tonsky<li>a steady drip of smaller experiments around <a href="https://github.com/Unrepl/unrepl"><code class="language-plaintext highlighter-rouge">unrepl</code></a>, <a href="https://github.com/Olical/propel"><code class="language-plaintext highlighter-rouge">propel</code></a>, and friends</ul><p>It was real momentum. If you were following Clojure tooling in 2018-2020, it genuinely felt like nREPL might be the past, and the future would be some combination of socket REPL plus a thin self-installing protocol on top of it. You can find a fair number of “RIP nREPL” hot takes from that period if you go looking.</p><h2 id="reality-check">Reality Check</h2><p>I went and surveyed each of those projects recently while working on Port. The pattern is depressingly consistent:</p><p><strong>Tutkain</strong> started on prepl. In November 2021, its v0.11 release explicitly stopped using prepl message framing and switched to a hand-rolled EDN-RPC protocol that Tutkain boots onto the raw socket REPL by sending it a base64-encoded blob. The new protocol has request ids, op dispatch (<code class="language-plaintext highlighter-rouge">:eval</code>, <code class="language-plaintext highlighter-rouge">:lookup</code>, <code class="language-plaintext highlighter-rouge">:completions</code>, <code class="language-plaintext highlighter-rouge">:test</code>, <code class="language-plaintext highlighter-rouge">:apropos</code>, <code class="language-plaintext highlighter-rouge">:interrupt</code>, …), and server-managed thread bindings. In other words: Tutkain grew into nREPL, just spelled differently.</p><p><strong>Chlorine</strong> never used prepl directly. It used socket REPL plus an <code class="language-plaintext highlighter-rouge">unrepl</code>-style upgrade blob. Its author’s successor project, <a href="https://github.com/mauricioszabo/lazuli">Lazuli</a>, abandoned the whole approach in favor of nREPL. The <a href="https://mauricio.szabo.link/blog/2024/11/09/goodbye-chlorine-and-welcome-lazuli/">post-mortem</a> is worth reading and is fairly blunt: tools that attempted prepl went back to nREPL because, honestly, it’s simply better.</p><p><strong>Conjure</strong> had a prepl client in its early Rust days. The current Lua/Fennel rewrite ships only an nREPL client. The author’s reasoning in the release notes was that nREPL “has complete ecosystem adoption and brilliant ClojureScript support.”</p><p><strong>Clojure-Sublimed</strong> technically still talks to a raw socket REPL, but only after sending it an EDN-printing prelude that upgrades the REPL to a structured protocol of tonsky’s own design. His <a href="https://tonsky.me/blog/clojure-sublimed-3/">post on the topic</a> is one of the most thoughtful pieces I’ve read on Clojure REPL design, and his conclusion is roughly: the bare socket REPL is <em>more</em> useful than prepl because you can install your own protocol on top of it. Which is true. But notice that everyone who reached that conclusion ended up reinventing the same wheel: ids, ops, request/response correlation, completion support, lookup, interrupts. You know, the things nREPL has had since 2010.</p><p>So the trajectory looks roughly like this:</p><ol><li>Editor decides nREPL is too heavy or an undesirable external dependency and starts on prepl.<li>Editor discovers prepl has no ids, no ops, no interrupts, no server-side completion, no namespace tracking, no test runner integration, etc.<li>Editor rolls a custom protocol on top of socket REPL, or…<li>Editor gives up and goes to nREPL.</ol><p>Pure prepl clients are nearly extinct in the wild. The one I found that qualifies is <a href="https://github.com/Olical/propel">propel</a> by Oliver Caldwell (of Conjure fame), which is delightful, about 70 lines of Clojure, and explicitly synchronous (one outstanding eval at a time). That works! But it’s not a foundation for the kind of feature set people expect from an editor.</p><h2 id="real-repl-vs-tool-friendly-repl">“Real” REPL vs. tool-friendly REPL</h2><p>Here’s where I land. Rich isn’t wrong that prepl is closer to a “real” REPL in the strict sense. prepl genuinely is a more faithful encoding of read-eval-print: each form goes in, each result comes out, and the semantics match what you’d get at the standard REPL prompt.</p><p>The thing is, “real REPL” is not the property you optimize for when you’re building editor tooling. The properties editor tooling actually needs are:</p><ul><li>A way to <strong>correlate</strong> a request with its response when output and results are interleaved.<li>A way to <strong>multiplex</strong> – one connection, several logical conversations.<li><strong>Server-side hooks</strong> for the operations every IDE expects: completion, lookup, go-to-definition, find-references, test running, stacktrace structuring, interrupt.<li>A <strong>protocol stable enough</strong> that ten different editors can target it without each one inventing its own dialect.</ul><p>nREPL was <em>explicitly designed</em> for those properties. The <a href="https://nrepl.org/nrepl/design/overview.html">ops, middleware, and transport</a> abstractions exist precisely because the people building it knew the consumers are not humans typing at a prompt, they’re programs negotiating a session.</p><p>Calling nREPL “not a real REPL” is technically defensible and practically beside the point. Nobody on the consuming end is confused about what nREPL is <em>for</em>.</p><h2 id="taking-stock-eight-years-later">Taking stock, eight years later</h2><p>I <a href="https://metaredux.com/posts/2018/10/29/nrepl-redux.html">wrote about nREPL’s revival in 2018</a>. At that point I had just finished migrating the project out of Clojure Contrib, and the goal was to give it a real home and a working development process. It was a lot of work, but in hindsight things played out pretty well.</p><p>Looking at where things ended up:</p><ul><li><strong>nREPL itself</strong> is healthier than it has ever been. Active maintainers, a proper <a href="https://nrepl.org">manual</a>, a steady release cadence, an actual ecosystem organization on GitHub.<li><strong>Most popular Clojure editors</strong> support it. <a href="https://github.com/clojure-emacs/cider">CIDER</a>, <a href="https://calva.io">Calva</a>, <a href="https://cursive-ide.com">Cursive</a> (via its own client), Conjure, <a href="https://github.com/liquidz/vim-iced">vim-iced</a>, you name it.<li><strong><a href="https://babashka.org">babashka</a></strong> ships with nREPL built in. You boot a <code class="language-plaintext highlighter-rouge">bb</code> and you get an nREPL server, no extra dependencies. That’s how a lot of people use nREPL in scripting contexts today, and it’s been a hit.<li><strong><a href="https://github.com/basilisp-lang/basilisp">basilisp</a></strong> (the Clojure dialect on Python) has <a href="https://github.com/basilisp-lang/basilisp-nrepl-async">nREPL support</a>. nREPL running on Python, talking to Emacs, evaluating Clojure. Nice.<li><strong><a href="https://github.com/clojure/clojure-clr">ClojureCLR</a></strong> has a working nREPL story now, and <strong><a href="https://jank-lang.org">jank</a></strong> (the C++ Clojure) has nREPL on its roadmap too.<li>The middleware ecosystem (<a href="https://github.com/clojure-emacs/cider-nrepl"><code class="language-plaintext highlighter-rouge">cider-nrepl</code></a>, <a href="https://github.com/clojure-emacs/refactor-nrepl"><code class="language-plaintext highlighter-rouge">refactor-nrepl</code></a>, <a href="https://github.com/nrepl/piggieback"><code class="language-plaintext highlighter-rouge">piggieback</code></a>, <a href="https://github.com/clojure-emacs/sayid"><code class="language-plaintext highlighter-rouge">sayid</code></a>, <a href="https://github.com/nrepl/drawbridge"><code class="language-plaintext highlighter-rouge">drawbridge</code></a>, …) is alive, well, and continues to add features.</ul><p>Meanwhile prepl is, as best as I can tell, mostly a curiosity. It got me a side project I had fun with. It did not displace nREPL.</p><h2 id="epilogue">Epilogue</h2><p>The history of tooling protocols is full of cases where “purer”, “simpler”, or “more elegant” lost to “shipped, documented, and battle-tested.” LSP beat fifteen ad-hoc language protocols. DAP beat the same fifteen debuggers. nREPL beat prepl in the (Clojure) editor space.</p><p>It’s not that the simpler thing is bad. prepl is a fine, elegant little protocol, and there’s a real case for embedding it in CI scripts, ops automation, deployment pipelines, or anywhere you want to drive a Clojure VM programmatically without pulling in a server library. Use it there.</p><p>But for editor tooling? The Clojure community made an enormous, multi-year, multi-tool investment in nREPL. We have the protocol, the middleware, the manual, the books, the conference talks. nREPL works, it’s actively maintained, it’s increasingly portable across Clojure dialects, and the design decisions that Rich called out as un-REPL-like are the exact ones that make it a good substrate for editors.</p><p>So I’ll say what I felt awkward saying back in 2018: nREPL forever. It’s the right abstraction for the job, and it’s not going anywhere.</p><p>One more thing. After finishing Port I got curious what a minimal nREPL client would look like by comparison, so I went and built one. As you can imagine, it turned out to be significantly simpler. If that sounds interesting, take a look at <a href="/articles/2026/05/20/neat-a-language-agnostic-nrepl-client-for-emacs/">neat</a>, a small, language-agnostic nREPL client for Emacs.</p><p>Keep hacking!</p>]]></content> <author> <name>bbatsov</name> </author> <summary>Last week I announced Port, a small prepl client for Emacs. That post focused on Port itself, but writing it left me with the itch to do a follow-up on the bigger picture, because the socket REPL / prepl story is one I’ve been meaning to write up for years. If you’ve been around Clojure long enough, you remember the chatter. Socket REPL landed in Clojure 1.8 (January 2016), prepl in Clojure 1....</summary> </entry> <entry><title>neat: a language-agnostic nREPL client for Emacs</title><link href="https://batsov.com/articles/2026/05/20/neat-a-language-agnostic-nrepl-client-for-emacs/" rel="alternate" type="text/html" title="neat: a language-agnostic nREPL client for Emacs" /><published>2026-05-20T09:00:00+03:00</published> <updated>2026-05-20T09:00:00+03:00</updated> <id>https://batsov.com/articles/2026/05/20/neat-a-language-agnostic-nrepl-client-for-emacs/</id> <content type="html"><![CDATA[<blockquote><p>I think I’ll take my REPL neat <br /> My parens black and my bed at three <br /> CIDER’s too sweet for me…</p><p>– Bozier</p></blockquote><p>Last week I <a href="/articles/2026/05/12/port-a-minimalist-prepl-client-for-emacs/">announced Port</a>, a small prepl client for Emacs. Today I’m following it up with another small Emacs package. Meet <a href="https://github.com/nrepl/neat">neat</a>, a tiny, deliberately language-agnostic nREPL client.</p><h2 id="why">Why?</h2><p>For years I’ve been hearing some version of the same request: “could CIDER work with my non-Clojure nREPL server?”. Babashka, Basilisp, nREPL-CLR, even some homegrown servers people built on top of nREPL for languages I’d never heard of.<sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup> The answer was always the same kind of squishy “sort of, in theory, with caveats”, because while bare nREPL is genuinely language-agnostic, CIDER is not. CIDER was built for Clojure and assumes Clojure pretty much everywhere.</p><p>I always thought the right answer was “let’s gradually make CIDER more language-agnostic.” That’s the kind of plan that sounds reasonable until you actually try it.</p><p>The thing that pushed me over the edge was, oddly enough, building Port. Port is small, focused, and doesn’t try to be CIDER. Working on it for a couple of weeks reminded me how (deceptively) productive it is to start from a clean slate when the new requirements don’t match the assumptions baked into a mature codebase. Trying to retrofit CIDER into a language-agnostic shape would have meant fighting with every helper that ever assumed <code class="language-plaintext highlighter-rouge">clojure.repl</code> exists, every middleware contract <code class="language-plaintext highlighter-rouge">cider-nrepl</code> defines, every project-type heuristic that knows about <code class="language-plaintext highlighter-rouge">deps.edn</code> and <code class="language-plaintext highlighter-rouge">project.clj</code> and nothing else. A whole lot of “is the server Clojure, or is it the other thing?” branches. The Port experience reaffirmed that the right move for a genuinely different client is a <em>new project</em>, not a thousand cuts to an existing one.</p><p>So <code class="language-plaintext highlighter-rouge">neat</code> was born. The name is short, says what it does (it’s neat, both in the small-and-tidy sense and in the “no deps, no special assumptions, just the protocol” sense), and conveniently leaves room for puns I haven’t fully committed to yet. I might land on a backronym one day. For now it’s just “neat”.</p><h2 id="what-neat-actually-is">What neat actually is</h2><p>neat is a small Emacs nREPL client. The code is split across four files:</p><ul><li><code class="language-plaintext highlighter-rouge">neat-bencode.el</code>: bencode encode/decode.<li><code class="language-plaintext highlighter-rouge">neat-client.el</code>: TCP connections, request dispatch, the standard nREPL ops.<li><code class="language-plaintext highlighter-rouge">neat-repl.el</code>: a comint-derived REPL buffer.<li><code class="language-plaintext highlighter-rouge">neat.el</code>: the entry point, customization group, and <code class="language-plaintext highlighter-rouge">neat-mode</code> minor mode for source buffers.</ul><p>It only uses Emacs builtins. There are no external runtime dependencies, not even on <code class="language-plaintext highlighter-rouge">clojure-mode</code>, because neat doesn’t assume Clojure on the other end. If you write <code class="language-plaintext highlighter-rouge">clojure-mode</code>, <code class="language-plaintext highlighter-rouge">fennel-mode</code>, <code class="language-plaintext highlighter-rouge">hy-mode</code>, or anything else that talks nREPL, you turn on <code class="language-plaintext highlighter-rouge">neat-mode</code> in that buffer and it just works.</p><p>The connection routing is also intentionally library-friendly. There’s a buffer-local <code class="language-plaintext highlighter-rouge">neat-current-connection</code> override so downstream packages can implement their own routing logic, plus a global default for the simple “one server at a time” case that most people will want.</p><p>Capability discovery is done at connect time via the nREPL <code class="language-plaintext highlighter-rouge">describe</code> op. neat doesn’t hardcode “this server has completions, this one doesn’t” assumptions. If the server reports a <code class="language-plaintext highlighter-rouge">completions</code> op, the CAPF backend lights up (with type annotations next to each candidate, when the server provides them). If it reports <code class="language-plaintext highlighter-rouge">lookup</code>, eldoc starts working and <code class="language-plaintext highlighter-rouge">M-.</code> jumps to definitions via an xref backend. If neither is there, you still get a perfectly serviceable raw REPL.</p><h2 id="basic-usage">Basic usage</h2><p>Start an nREPL server. Anything that speaks the protocol will do. For a Clojure server:</p><div class="language-plaintext highlighter-rouge"><div class="highlight">class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre><td class="rouge-code"><pre>clj -M:nrepl
# or
bb nrepl-server :port 7888
# or
lein repl :headless :port 7888
</pre></div></div><p>Then in Emacs:</p><div class="language-plaintext highlighter-rouge"><div class="highlight">class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre><td class="rouge-code"><pre>M-x neat RET localhost RET 7888 RET
</pre></div></div><p>A REPL buffer pops up, the prompt follows the server’s reported namespace, and you can type expressions at it. Multi-line input works because <code class="language-plaintext highlighter-rouge">RET</code> only submits when the form parses as balanced under <code class="language-plaintext highlighter-rouge">neat-repl-input-syntax-table</code> (Emacs Lisp syntax by default, which is close enough for any Lisp). Input history is persisted across sessions.</p><p>If there’s a <code class="language-plaintext highlighter-rouge">.nrepl-port</code> file in the project, the prompt defaults to its contents, so <code class="language-plaintext highlighter-rouge">M-x neat RET RET</code> is enough to connect.</p><p>To evaluate from a source buffer, turn on the minor mode:</p><div class="language-plaintext highlighter-rouge"><div class="highlight">class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre><td class="rouge-code"><pre>M-x neat-mode
</pre></div></div><p>The familiar bindings are there, intentionally compatible with what CIDER users expect:</p><table><thead><tr><th>Key<th>Command<tbody><tr><td><code class="language-plaintext highlighter-rouge">C-c C-e</code><td><code class="language-plaintext highlighter-rouge">neat-eval-last-sexp</code><tr><td><code class="language-plaintext highlighter-rouge">C-c C-c</code><td><code class="language-plaintext highlighter-rouge">neat-eval-defun</code><tr><td><code class="language-plaintext highlighter-rouge">C-c C-r</code><td><code class="language-plaintext highlighter-rouge">neat-eval-region</code><tr><td><code class="language-plaintext highlighter-rouge">C-c C-b</code><td><code class="language-plaintext highlighter-rouge">neat-eval-buffer</code><tr><td><code class="language-plaintext highlighter-rouge">C-c C-l</code><td><code class="language-plaintext highlighter-rouge">neat-load-buffer-file</code><tr><td><code class="language-plaintext highlighter-rouge">C-c C-z</code><td><code class="language-plaintext highlighter-rouge">neat-switch-to-repl</code><tr><td><code class="language-plaintext highlighter-rouge">C-c C-k</code><td><code class="language-plaintext highlighter-rouge">neat-interrupt-eval</code><tr><td><code class="language-plaintext highlighter-rouge">C-c M-n</code><td><code class="language-plaintext highlighter-rouge">neat-set-ns</code><tr><td><code class="language-plaintext highlighter-rouge">C-c C-d C-d</code><td><code class="language-plaintext highlighter-rouge">neat-show-doc-at-point</code><tr><td><code class="language-plaintext highlighter-rouge">M-.</code><td><code class="language-plaintext highlighter-rouge">xref-find-definitions</code><tr><td><code class="language-plaintext highlighter-rouge">M-,</code><td><code class="language-plaintext highlighter-rouge">xref-go-back</code></table><p><code class="language-plaintext highlighter-rouge">neat-eval-buffer</code> ships the buffer contents as an <code class="language-plaintext highlighter-rouge">eval</code> op; <code class="language-plaintext highlighter-rouge">neat-load-buffer-file</code> uses the standard <code class="language-plaintext highlighter-rouge">load-file</code> op instead, so the server can attribute file and line numbers to errors. Use the latter when you’re actually loading a file from disk and care about good diagnostics.</p><p><code class="language-plaintext highlighter-rouge">neat-set-ns</code> sets the buffer-local <code class="language-plaintext highlighter-rouge">neat-ns</code>, which gets sent as the <code class="language-plaintext highlighter-rouge">ns</code> field on every <code class="language-plaintext highlighter-rouge">eval</code> op from that buffer. For languages where the namespace is declared in the source (Clojure’s <code class="language-plaintext highlighter-rouge">(ns foo.bar)</code>, etc.), swap in a parser via <code class="language-plaintext highlighter-rouge">neat-buffer-ns-function</code>.</p><p>For juggling multiple connections, <code class="language-plaintext highlighter-rouge">M-x neat-list-connections</code> opens a tabulated-list buffer with one row per live connection, where you can set the default or disconnect interactively.</p><p>That’s roughly the whole user-facing surface today. There’s no jack-in command, no inspector, no debugger, no test runner. Likely there will never be, but if you need those you should probably be using CIDER anyways…</p><h2 id="should-you-use-it">Should you use it?</h2><p>If you write Clojure and CIDER works for you, keep using CIDER. It’s mature, full-featured, and supported, and I’m going to keep working on it for as long as people use it. Nothing about neat changes that.</p><p>But if you find yourself in one of these situations:</p><ul><li>you write a non-Clojure language whose runtime ships an nREPL server, and you’ve been muddling through with a half-supported CIDER setup,<li>you write Clojure but you value minimalism and don’t need the full CIDER feature set,<li>you’re building an Emacs package that needs to talk nREPL and you want a small, dependency-free library to build on,<sup id="fnref:2"><a href="#fn:2" class="footnote" rel="footnote" role="doc-noteref">2</a></sup></ul><p>then neat might be a better fit. It’s small enough that you can read the whole thing in an afternoon, and the library/UI split (<code class="language-plaintext highlighter-rouge">neat-bencode</code> and <code class="language-plaintext highlighter-rouge">neat-client</code> are perfectly usable from other packages) is genuinely designed for downstream consumers.</p><h2 id="the-bigger-picture">The bigger picture</h2><p>neat is part of a broader push I’ve been chewing on for a while now: making nREPL a healthy multi-language ecosystem rather than a Clojure-only protocol. That push has three legs:</p><ol><li><strong>An actual nREPL specification.</strong> The <a href="https://github.com/nrepl/spec.nrepl.org">spec.nrepl.org</a> draft is (will be) the formal version of what today is “whatever nREPL the project does”.<li><strong>Reference clients.</strong> neat is one. The point of building a deliberately Clojure-free client is that it stress-tests the spec. Anywhere neat ends up needing to special-case the server, the spec has a gap.<li><strong>A compatibility test suite.</strong> The parameterised integration suite in neat already runs the same assertions against multiple servers and surfaces real divergences (Clojure batching <code class="language-plaintext highlighter-rouge">(println "hi")</code> into a single <code class="language-plaintext highlighter-rouge">out</code> message where Basilisp emits two, for example). I’d like to grow this into a portable suite that any nREPL server can self-check against.</ol><p>This is also why I keep teasing a “reference CLI client” in conversations. An editor client is one thing, but a small command-line nREPL client written in a non-Lisp language would be a much sharper test of how language-agnostic the protocol really is. neat is plausibly a precursor to that. Time will tell how far I push this; for now I just wanted to get the Emacs side moving.</p><h2 id="thanks">Thanks</h2><p>As always, big thanks to <a href="https://www.clojuriststogether.org/">Clojurists Together</a> and everyone supporting my open source work. You make it possible for me to keep tweaking and improving CIDER, nREPL, clj-refactor, and friends, and occasionally try something “neat” on the side. <code class="language-plaintext highlighter-rouge">neat</code> isn’t replacing any of the existing Clojure tooling for Emacs. It’s just another tool in the box for the people who want it.</p><p>Feedback, ideas, and contributions are most welcome over at the <a href="https://github.com/nrepl/neat/issues">issue tracker</a>.</p><p>Keep hacking!</p><div class="footnotes" role="doc-endnotes"><ol><li id="fn:1"><p><a href="https://github.com/clojure-emacs/cider/issues/3905">https://github.com/clojure-emacs/cider/issues/3905</a> <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&amp;#8617;&amp;#xfe0e;</a></p><li id="fn:2"><p>For a long time I planned to extract CIDER’s nREPL client code into a reusable package, but now that we have <code class="language-plaintext highlighter-rouge">neat</code> I probably will finally abandon this idea. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&amp;#8617;&amp;#xfe0e;</a></p></ol></div>]]></content> <author> <name>bbatsov</name> </author> <summary>I think I’ll take my REPL neat My parens black and my bed at three CIDER’s too sweet for me… – Bozier Last week I announced Port, a small prepl client for Emacs. Today I’m following it up with another small Emacs package. Meet neat, a tiny, deliberately language-agnostic nREPL client. Why? For years I’ve been hearing some version of the same request: “could CIDER work with my non-Cloju...</summary> </entry> </feed>
