<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://batsov.com/feeds/Emacs.xml" rel="self" type="application/atom+xml" /><link href="https://batsov.com/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-07-24T08:47:39+03:00</updated><id>https://batsov.com/feeds/Emacs.xml</id><title type="html">(think)</title><subtitle>Bozhidar Batsov&apos;s personal blog</subtitle><entry><title type="html">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" xml:base="https://batsov.com/articles/2026/06/24/neocaml-0-9-repl-completion-and-robustness/"><![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>
  <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"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><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></td></tr></tbody></table></code></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>
  <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>
  <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>
  <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.</li>
</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|))   ; &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" {&gt;= "3.0"}
  "cmd|"   ; &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>
  <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.</li>
</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>Bozhidar Batsov</name></author><category term="OCaml" /><category term="Emacs" /><summary type="html"><![CDATA[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.]]></summary></entry><entry><title type="html">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" xml:base="https://batsov.com/articles/2026/06/11/emacs-loves-asciidoc/"><![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>
  <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></li>
</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>
  <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>
  <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>
  <li><strong>Tooling integration.</strong> Asciidoctor-backed preview/export and a Flymake
checker that reports parser errors inline.</li>
  <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.</li>
</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">&#8617;&#xfe0e;</a></p>
    </li>
    <li id="fn:2">
      <p>Afterwards we’ll aim for world domination, of course. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;&#xfe0e;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Bozhidar Batsov</name></author><category term="Emacs" /><category term="AsciiDoc" /><summary type="html"><![CDATA[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 something like DocBook.]]></summary></entry><entry><title type="html">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" xml:base="https://batsov.com/articles/2026/05/20/neat-a-language-agnostic-nrepl-client-for-emacs/"><![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>
  <li><code class="language-plaintext highlighter-rouge">neat-client.el</code>: TCP connections, request dispatch, the standard
nREPL ops.</li>
  <li><code class="language-plaintext highlighter-rouge">neat-repl.el</code>: a comint-derived REPL buffer.</li>
  <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.</li>
</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"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre>clj -M:nrepl
# or
bb nrepl-server :port 7888
# or
lein repl :headless :port 7888
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Then in Emacs:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>M-x neat RET localhost RET 7888 RET
</pre></td></tr></tbody></table></code></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"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre>M-x neat-mode
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The familiar bindings are there, intentionally compatible with what CIDER
users expect:</p>

<table>
  <thead>
    <tr>
      <th>Key</th>
      <th>Command</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C-c C-e</code></td>
      <td><code class="language-plaintext highlighter-rouge">neat-eval-last-sexp</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C-c C-c</code></td>
      <td><code class="language-plaintext highlighter-rouge">neat-eval-defun</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C-c C-r</code></td>
      <td><code class="language-plaintext highlighter-rouge">neat-eval-region</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C-c C-b</code></td>
      <td><code class="language-plaintext highlighter-rouge">neat-eval-buffer</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C-c C-l</code></td>
      <td><code class="language-plaintext highlighter-rouge">neat-load-buffer-file</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C-c C-z</code></td>
      <td><code class="language-plaintext highlighter-rouge">neat-switch-to-repl</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C-c C-k</code></td>
      <td><code class="language-plaintext highlighter-rouge">neat-interrupt-eval</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C-c M-n</code></td>
      <td><code class="language-plaintext highlighter-rouge">neat-set-ns</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C-c C-d C-d</code></td>
      <td><code class="language-plaintext highlighter-rouge">neat-show-doc-at-point</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">M-.</code></td>
      <td><code class="language-plaintext highlighter-rouge">xref-find-definitions</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">M-,</code></td>
      <td><code class="language-plaintext highlighter-rouge">xref-go-back</code></td>
    </tr>
  </tbody>
</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>
  <li>you write Clojure but you value minimalism and don’t need the full
CIDER feature set,</li>
  <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></li>
</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>
  <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>
  <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.</li>
</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">&#8617;&#xfe0e;</a></p>
    </li>
    <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">&#8617;&#xfe0e;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Bozhidar Batsov</name></author><category term="nREPL" /><category term="Emacs" /><category term="neat" /><category term="Clojure" /><summary type="html"><![CDATA[I think I’ll take my REPL neat My parens black and my bed at three CIDER’s too sweet for me… – Bozier]]></summary></entry><entry><title type="html">Port: a minimalist prepl client for Emacs</title><link href="https://batsov.com/articles/2026/05/12/port-a-minimalist-prepl-client-for-emacs/" rel="alternate" type="text/html" title="Port: a minimalist prepl client for Emacs" /><published>2026-05-12T09:00:00+03:00</published><updated>2026-05-12T09:00:00+03:00</updated><id>https://batsov.com/articles/2026/05/12/port-a-minimalist-prepl-client-for-emacs</id><content type="html" xml:base="https://batsov.com/articles/2026/05/12/port-a-minimalist-prepl-client-for-emacs/"><![CDATA[<p>For ages I’ve had “add prepl support to CIDER” sitting somewhere in the back of
my head. CIDER is built firmly around nREPL, but
<a href="https://clojure.org/reference/repl_and_main#prepl">prepl</a> ships with Clojure
itself, and the appeal of dropping the external REPL server requirement is
obvious.  Recently, as part of a broader internals cleanup “mini-project” in
CIDER, I finally sat down and put a prototype together:
<a href="https://github.com/clojure-emacs/cider/pull/3899">cider#3899</a>.</p>

<p>The good news is that the prototype sort of worked. The bad news is that the
more I poked at it, the more I kept running into the same pattern. CIDER
assumes ops, sessions, request ids, and a whole structured protocol that
prepl simply doesn’t have. The amount of CIDER code that would need to grow
“is this nREPL or prepl?” branches added up quickly, and I’d be papering
over prepl’s limitations in dozens of subtle places. The exercise was fun,
but it ended up reaffirming my long-standing belief that nREPL is a much
better fit for editor tooling than prepl is.</p>

<p>The exercise did leave me thinking though. What if, instead of bolting prepl
onto CIDER, I built a small standalone client in the spirit of
<a href="https://github.com/clojure-emacs/inf-clojure">inf-clojure</a> and
<a href="https://github.com/sanel/monroe">monroe</a>? Something tiny and focused that
doesn’t have to pretend to be CIDER, and where prepl’s quirks would be the
design rather than something to work around.</p>

<p>Conveniently, I was on vacation in Portugal at the time, where I spent a few
days in Porto, and the name pretty much picked itself.
<a href="https://github.com/clojure-emacs/port">Port</a> was born. It kept us firmly in the
land of fun, drink-inspired Clojure-on-editor names: CIDER,
<a href="https://calva.io">Calva</a> (after Calvados, the apple brandy), and now Port (the
famous fortified wine). The protocol Port talks to is <code class="language-plaintext highlighter-rouge">prepl</code>, over a TCP
<strong>port</strong>, so the pun was hard to pass up.</p>

<p>This time around I didn’t manage to land on a backronym I love (at least not
yet). The contenders so far:</p>

<ul>
  <li>prepl omnipotent repl toolkit (my favorite so far)</li>
  <li>prepl-operated repl toolkit</li>
  <li>peak optimized repl toolkit</li>
</ul>

<p>Naming is hard. I remain open to better suggestions. :D</p>

<h2 id="scope-or-what-port-isnt">Scope, or what Port isn’t</h2>

<p>Port is a side project. I don’t plan to invest serious time in it past the
point I consider it feature-complete, which won’t be far beyond what’s
already there. The deliberate goal is to keep it simple and focused, and its
feature set will stay close to inf-clojure and monroe. <strong>Port is not
competing with CIDER.</strong> If you want the full feature set (debugger,
inspector, test runner, profiler, structured stacktraces, refactor support),
CIDER + <code class="language-plaintext highlighter-rouge">cider-nrepl</code> is, and will remain, the way.</p>

<p>What Port gives you today is a small, dependable Clojure REPL that you can
hook into Emacs without any external dependencies, just a stock Clojure JVM
with a prepl listening on a port.</p>

<h2 id="a-few-architectural-notes">A few architectural notes</h2>

<p>If you’re up for the long version, <a href="https://github.com/clojure-emacs/port/blob/main/doc/design.md">doc/design.md</a>
goes deep. Here’s the short version of what prepl gives you compared to nREPL:</p>

<ul>
  <li><strong>No bencode</strong>. prepl emits EDN-tagged maps, one per line. This might be a feature or a problem, depending on your perspective.</li>
  <li><strong>No middleware</strong>. Whatever the server prints is what you get. No
interception, no extension surface.</li>
  <li><strong>No sessions</strong>. There’s one thread per TCP connection.</li>
  <li><strong>No ops</strong>. You send a Clojure form, the server evaluates it, and prints
back tagged messages: <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>, plus an <code class="language-plaintext highlighter-rouge">:exception
true</code> flag on errors.</li>
  <li><strong>No request id</strong>. This is the main issue. Tags identify the <em>kind</em> of
message, not which request produced it.</li>
</ul>

<p>That last point is the central design constraint. If you want to issue a
request and reliably read back its result without accidentally consuming
output from an unrelated background <code class="language-plaintext highlighter-rouge">future</code> or <code class="language-plaintext highlighter-rouge">tap&gt;</code>, you need to layer
correlation on top of the protocol. Port does this with two tricks:</p>

<ol>
  <li><strong>Two sockets per session.</strong> A user socket drives the REPL buffer with
raw streaming output, and a separate tool socket carries helper-command
requests. Background prints from <code class="language-plaintext highlighter-rouge">future</code>/<code class="language-plaintext highlighter-rouge">agent</code> on the user thread
don’t bleed into the tool channel.</li>
  <li><strong>A bootstrap form.</strong> On connect, the tool socket evaluates a one-shot
form that defines a <code class="language-plaintext highlighter-rouge">port.tooling/-eval</code> wrapper. Every subsequent helper
call goes through it, which captures <code class="language-plaintext highlighter-rouge">*out*</code> / <code class="language-plaintext highlighter-rouge">*err*</code> and returns a
tagged map containing the request id. The client matches the id against
a pending-callback registry.</li>
</ol>

<p>This is what nREPL provides via sessions and ops, just reinvented at the TCP
layer. It’s a fair amount of work for something nREPL gives you for free,
which only strengthens my view that nREPL is the better protocol for editor
tooling. Still, it was an interesting and educational exercise.</p>

<h2 id="zero-dependencies">Zero dependencies</h2>

<p>One thing I’m fairly proud of: Port has no hard dependencies. You’ll want either
<code class="language-plaintext highlighter-rouge">clojure-mode</code> or <code class="language-plaintext highlighter-rouge">clojure-ts-mode</code> installed for the source-buffer side of
things, but Port itself only soft-depends on them via runtime <code class="language-plaintext highlighter-rouge">fboundp</code>
checks. Hook it onto whichever one(s) you actually use. I intend to keep it that
way. Dependency creep is a real problem in the Emacs (every?) ecosystem, and a small
package should stay small.</p>

<h2 id="whats-there-in-01">What’s there in 0.1</h2>

<p>I tagged <a href="https://github.com/clojure-emacs/port/releases/tag/v0.1.0">v0.1.0</a>
yesterday. It’s small but already perfectly usable:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">M-x port</code> “jacks in” (bootstraps) (auto-detects <code class="language-plaintext highlighter-rouge">deps.edn</code> / <code class="language-plaintext highlighter-rouge">project.clj</code> /
<code class="language-plaintext highlighter-rouge">bb.edn</code>, starts a server and connects to it)</li>
  <li>single-buffer REPL with persistent input history, completion, and eldoc
at the prompt</li>
  <li>interactive evaluation from source buffers with pretty-printed results</li>
  <li>structured stacktrace buffer with cause chain and navigable frames</li>
  <li><code class="language-plaintext highlighter-rouge">M-.</code> find-definition that follows into jar sources</li>
  <li>doc/source/apropos/macroexpand helpers</li>
</ul>

<p>MELPA submission is queued up next. After that, expect Port to be in
<a href="/articles/2025/11/16/burst-driven-development-my-approach-to-oss-projects-maintenance/">burst-driven</a>
maintenance mode like most of my smaller projects.</p>

<p>Feedback, ideas, and contributions are most welcome. The
<a href="https://github.com/clojure-emacs/port/issues">issue tracker</a> is the right
place.</p>

<h2 id="closing-thoughts">Closing thoughts</h2>

<p>Funny thing, I’d never actually written any code against prepl until I
started this project. It was fun to spend some quality time with the
“competition” of my beloved nREPL. Working with a different protocol always
teaches you something about the one you’re used to, and I came away from
this with a renewed appreciation for both: prepl is genuinely elegant for
what it is, and nREPL is genuinely well-designed for what we use it for.</p>

<p>Big thanks to <a href="https://www.clojuriststogether.org/">Clojurists Together</a> and
everyone else who supports my OSS Clojure work. You rock! Now if you’ll
excuse me, I have new releases of CIDER, clj-refactor, and refactor-nrepl
to get back to.</p>

<p>Keep hacking!</p>]]></content><author><name>Bozhidar Batsov</name></author><category term="Clojure" /><category term="Emacs" /><category term="Port" /><category term="prepl" /><summary type="html"><![CDATA[For ages I’ve had “add prepl support to CIDER” sitting somewhere in the back of my head. CIDER is built firmly around nREPL, but prepl ships with Clojure itself, and the appeal of dropping the external REPL server requirement is obvious. Recently, as part of a broader internals cleanup “mini-project” in CIDER, I finally sat down and put a prototype together: cider#3899.]]></summary></entry><entry><title type="html">Batppuccin: My Take on Catppuccin for Emacs</title><link href="https://batsov.com/articles/2026/03/29/batppuccin-my-take-on-catppuccin-for-emacs/" rel="alternate" type="text/html" title="Batppuccin: My Take on Catppuccin for Emacs" /><published>2026-03-29T10:00:00+03:00</published><updated>2026-03-29T10:00:00+03:00</updated><id>https://batsov.com/articles/2026/03/29/batppuccin-my-take-on-catppuccin-for-emacs</id><content type="html" xml:base="https://batsov.com/articles/2026/03/29/batppuccin-my-take-on-catppuccin-for-emacs/"><![CDATA[<p>I promised I’d take a break from building Tree-sitter major modes, and I meant
it. So what better way to relax than to build… color themes? Yeah, I know. My
idea of chilling is weird, but I genuinely enjoy working on random Emacs packages.
Most of the time at least…</p>

<h2 id="some-background">Some Background</h2>

<p>For a very long time my go-to Emacs themes were
<a href="https://github.com/bbatsov/zenburn-emacs">Zenburn</a> and
<a href="https://github.com/bbatsov/solarized-emacs">Solarized</a> – both of which I
maintain popular Emacs ports for. Zenburn was actually one of my very first open
source projects (created way back in 2010, when Emacs 24 was brand new). It served
me well for years.</p>

<p>But at some point I got bored. You know the feeling – you’ve been staring at the
same color palette for so long that you stop seeing it. My experiments with other
editors (Helix, Zed, VS Code) introduced me to
<a href="https://github.com/enkia/tokyo-night-vscode-theme">Tokyo Night</a> and
<a href="https://github.com/catppuccin/catppuccin">Catppuccin</a>, and they’ve been my daily
drivers since then.</p>

<p>Eventually, I ended up creating my own Emacs ports of both. I’ve already published
<a href="https://github.com/bbatsov/tokyo-night-emacs">Tokyo Night for Emacs</a>,
and I’ll write more about that one down the road. Today is all about Catppuccin.
(and by this I totally mean Batppuccin!)</p>

<h2 id="why-another-catppuccin-port">Why Another Catppuccin Port?</h2>

<p>There’s already an <a href="https://github.com/catppuccin/emacs">official Catppuccin theme for
Emacs</a>, and it works. So why build another
one? A few reasons.</p>

<p>The official port registers a single <code class="language-plaintext highlighter-rouge">catppuccin</code> theme and switches between
flavors (Mocha, Macchiato, Frappe, Latte) via a global variable and a reload
function. This is unusual by Emacs standards and breaks the normal <code class="language-plaintext highlighter-rouge">load-theme</code>
workflow – theme-switching packages like <code class="language-plaintext highlighter-rouge">circadian.el</code> need custom glue code to
work with it. It also loads color definitions from an external file in a way that
fails when Emacs hasn’t marked the theme as safe yet, which means some users
can’t load the theme at all.</p>

<p>Beyond the architecture, there are style guide issues.
<code class="language-plaintext highlighter-rouge">font-lock-variable-name-face</code> is set to the default text color, making variables
invisible. All <code class="language-plaintext highlighter-rouge">outline-*</code> levels use the same blue, so org-mode headings are
flat. <code class="language-plaintext highlighter-rouge">org-block</code> forces green on all unstyled code. Several faces still ship
with <code class="language-plaintext highlighter-rouge">#ff00ff</code> magenta placeholder colors. And there’s no support for popular
packages like vertico, marginalia, transient, flycheck, or cider.</p>

<p>I think some of this comes from the official port trying to match the structure of
the Neovim version, which makes sense for their cross-editor tooling but doesn’t
sit well with how Emacs does things.<sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup></p>

<h2 id="meet-batppuccin">Meet Batppuccin</h2>

<p><a href="https://github.com/bbatsov/batppuccin-emacs">Batppuccin</a> is my opinionated take
on Catppuccin for Emacs. The name is a play on my last name (Batsov) + Catppuccin.<sup id="fnref:2"><a href="#fn:2" class="footnote" rel="footnote" role="doc-noteref">2</a></sup>
I guess you can think of this as <code class="language-plaintext highlighter-rouge">@bbatsov</code>’s Catppuccin… or perhaps Batman’s
Catppuccin?</p>

<p>The key differences from the official port:</p>

<p><strong>Four proper themes.</strong> <code class="language-plaintext highlighter-rouge">batppuccin-mocha</code>, <code class="language-plaintext highlighter-rouge">batppuccin-macchiato</code>,
<code class="language-plaintext highlighter-rouge">batppuccin-frappe</code>, and <code class="language-plaintext highlighter-rouge">batppuccin-latte</code> are all separate themes that work
with <code class="language-plaintext highlighter-rouge">load-theme</code> out of the box. No special reload dance needed.</p>

<p><strong>Faithful to the style guide.</strong> Mauve for keywords, green for strings, blue for
functions, peach for constants, sky for operators, yellow for types, overlay2 for
comments, rosewater for the cursor. The rainbow heading cycle (red, peach, yellow,
green, sapphire, lavender) makes org-mode and outline headings actually
distinguishable.</p>

<p><strong>Broad face coverage.</strong> Built-in Emacs faces plus magit, vertico, corfu,
marginalia, embark, orderless, consult, transient, flycheck, cider, company,
doom-modeline, treemacs, web-mode, and more. No placeholder colors.</p>

<p><strong>Clean architecture.</strong> Shared infrastructure in <code class="language-plaintext highlighter-rouge">batppuccin-themes.el</code>, thin
wrapper files for each flavor, color override mechanism, configurable heading
scaling. The same pattern I use in
<a href="https://github.com/bbatsov/zenburn-emacs">Zenburn</a> and
<a href="https://github.com/bbatsov/tokyo-night-emacs">Tokyo Night</a>.</p>

<p>I didn’t really re-invent anything here - I just created a theme in a way I’m
comfortable with.</p>

<p>Here’s what the four flavors look like, all showing the same Clojure snippet:</p>

<p><img src="/assets/img/batppuccin-flavors.png" alt="The four Batppuccin flavors" /></p>

<p>It looks like Catppuccin, because it <em>is</em> Catppuccin. There are small visual
differences if you know where to look (headings, variables, a few face tweaks),
but most people wouldn’t notice them side by side. If you’ve seen Catppuccin, you
know what to expect.</p>

<h2 id="installation">Installation</h2>

<p>The easiest way to install it right now:</p>

<div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre><span class="p">(</span><span class="nb">use-package</span> <span class="nv">batppuccin</span>
  <span class="ss">:vc</span> <span class="p">(</span><span class="ss">:url</span> <span class="s">"https://github.com/bbatsov/batppuccin-emacs"</span> <span class="ss">:rev</span> <span class="ss">:newest</span><span class="p">)</span>
  <span class="ss">:config</span>
  <span class="p">(</span><span class="nv">load-theme</span> <span class="ss">'batppuccin-mocha</span> <span class="no">t</span><span class="p">))</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Replace <code class="language-plaintext highlighter-rouge">mocha</code> with <code class="language-plaintext highlighter-rouge">macchiato</code>, <code class="language-plaintext highlighter-rouge">frappe</code>, or <code class="language-plaintext highlighter-rouge">latte</code> for the other flavors. You
can also switch interactively with <code class="language-plaintext highlighter-rouge">M-x batppuccin-select</code>.</p>

<h2 id="there-can-never-be-enough-theme-ports">There Can Never Be Enough Theme Ports</h2>

<p>I remember when Solarized was the hot new thing and there were something like five
competing Emacs ports of it. People had strong opinions about which one got the
colors right, which one had better org-mode support, which one worked with their
favorite completion framework. And that was fine! Different ports serve different
needs and different tastes.</p>

<p>The same applies here. The official Catppuccin port is perfectly usable for a lot
of people. Batppuccin is for people who want something more idiomatic to
Emacs, with broader face coverage and stricter adherence to the upstream style
guide. Both can coexist happily.</p>

<p>I’ve said many times that for me the best aspect of Emacs is that you can tweak
it infinitely to make it your own, so as far as I’m concerned having a theme
that you’re the only user of is perfectly fine. That being said, I hope
a few of you will appreciate my take on Catppuccin as well.</p>

<h2 id="wrapping-up">Wrapping Up</h2>

<p>This is an early release and there’s plenty of room for improvement. I’m sure
there are faces I’ve missed, colors that could be tweaked, and packages that
deserve better support. If you try it out and something looks off, please <a href="https://github.com/bbatsov/batppuccin-emacs/issues">open
an issue</a> or send a PR.</p>

<p>I’m also curious – what are your favorite Emacs themes these days? Still rocking
Zenburn? Converted to modus-themes? Something else entirely? I’d love to hear
about it.</p>

<p>That’s all from me, folks! Keep hacking!</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1">
      <p>The official port uses Catppuccin’s <a href="https://github.com/catppuccin/toolbox/tree/main/whiskers">Whiskers</a> template tool to generate the Elisp from a <code class="language-plaintext highlighter-rouge">.tera</code> template, which is cool for keeping ports in sync across editors but means the generated code doesn’t follow Emacs conventions. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;&#xfe0e;</a></p>
    </li>
    <li id="fn:2">
      <p>Naming is hard, but it should also be fun! Also – I’m a huge fan of Batman. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;&#xfe0e;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Bozhidar Batsov</name></author><category term="Emacs" /><category term="Themes" /><summary type="html"><![CDATA[I promised I’d take a break from building Tree-sitter major modes, and I meant it. So what better way to relax than to build… color themes? Yeah, I know. My idea of chilling is weird, but I genuinely enjoy working on random Emacs packages. Most of the time at least…]]></summary></entry><entry><title type="html">fsharp-ts-mode: A Modern Emacs Mode for F#</title><link href="https://batsov.com/articles/2026/03/27/fsharp-ts-mode-a-modern-emacs-mode-for-fsharp/" rel="alternate" type="text/html" title="fsharp-ts-mode: A Modern Emacs Mode for F#" /><published>2026-03-27T17:00:00+02:00</published><updated>2026-03-27T17:00:00+02:00</updated><id>https://batsov.com/articles/2026/03/27/fsharp-ts-mode-a-modern-emacs-mode-for-fsharp</id><content type="html" xml:base="https://batsov.com/articles/2026/03/27/fsharp-ts-mode-a-modern-emacs-mode-for-fsharp/"><![CDATA[<p>I’m pretty much done with the focused development push on
<a href="https://github.com/bbatsov/neocaml">neocaml</a> – it’s reached a point where I’m
genuinely happy using it daily and the remaining work is mostly incremental
polish. So naturally, instead of taking a break I decided it was time to start
another project that’s been living in the back of my head for a while:
a proper Tree-sitter-based F# mode for Emacs.</p>

<p>Meet <a href="https://github.com/bbatsov/fsharp-ts-mode">fsharp-ts-mode</a>.</p>

<h2 id="why-f">Why F#?</h2>

<p>I’ve written before about my fondness for the ML family of languages, and while
OCaml gets most of my attention, last year I developed a soft spot for F#. In some
ways I like it even a bit more than OCaml – the tooling is excellent, the .NET
ecosystem is massive, and computation expressions are one of the most elegant
abstractions I’ve seen in any language. F# manages to feel both practical and
beautiful, which is a rare combination.</p>

<p>The problem is that Emacs has never been particularly popular with F# programmers
– or .NET programmers in general. The existing
<a href="https://github.com/fsharp/emacs-fsharp-mode">fsharp-mode</a> works, but it’s
showing its age: regex-based highlighting, SMIE indentation with quirks, and
some legacy code dating back to the caml-mode days. I needed a good F# mode for
Emacs, and that’s enough of a reason to build one in my book.</p>

<h2 id="the-name">The Name</h2>

<p>I’ll be honest – I spent quite a bit of time trying to come up with
a clever name.<sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup> Some candidates that didn’t make the cut:</p>

<ul>
  <li><strong>fsharpe-mode</strong> (fsharp(evolved/enhanced)-mode)</li>
  <li><strong>Fa Dièse</strong> (French for F sharp – because after spending time with OCaml you
start thinking in French, apparently)</li>
  <li><strong>fluoride</strong> (a play on <a href="https://ionide.io/">Ionide</a>, the popular F# IDE
extension)</li>
</ul>

<p>In the end none of my fun ideas stuck, so I went with the boring-but-obvious
<code class="language-plaintext highlighter-rouge">fsharp-ts-mode</code>. Sometimes the straightforward choice is the right one. At
least nobody will have trouble finding it.<sup id="fnref:2"><a href="#fn:2" class="footnote" rel="footnote" role="doc-noteref">2</a></sup></p>

<h2 id="built-on-neocamls-foundation">Built on neocaml’s Foundation</h2>

<p>I modeled <code class="language-plaintext highlighter-rouge">fsharp-ts-mode</code> directly after <code class="language-plaintext highlighter-rouge">neocaml</code>, and the two packages share
a lot of structural similarities – which shouldn’t be surprising given how much
OCaml and F# have in common. The same architecture (base mode + language-specific
derived modes), the same approach to font-locking (shared + grammar-specific
rules), the same REPL integration pattern (<code class="language-plaintext highlighter-rouge">comint</code> with tree-sitter input
highlighting), the same build system interaction pattern (minor mode wrapping CLI
commands).</p>

<p>This also meant I could get the basics in place really quickly. Having already
solved problems like trailing comment indentation, <code class="language-plaintext highlighter-rouge">forward-sexp</code> hybrid
navigation, and <code class="language-plaintext highlighter-rouge">imenu</code> with qualified names in neocaml, porting those solutions
to F# was mostly mechanical.</p>

<h2 id="whats-in-010">What’s in 0.1.0</h2>

<p>The initial release covers all the essentials:</p>

<ul>
  <li><strong>Syntax highlighting</strong> via Tree-sitter with 4 customizable levels, supporting
<code class="language-plaintext highlighter-rouge">.fs</code>, <code class="language-plaintext highlighter-rouge">.fsx</code>, and <code class="language-plaintext highlighter-rouge">.fsi</code> files</li>
  <li><strong>Indentation</strong> via Tree-sitter indent rules</li>
  <li><strong>Imenu</strong> with fully-qualified names (e.g., <code class="language-plaintext highlighter-rouge">MyModule.myFunc</code>)</li>
  <li><strong>Navigation</strong> – <code class="language-plaintext highlighter-rouge">beginning-of-defun</code>, <code class="language-plaintext highlighter-rouge">end-of-defun</code>, <code class="language-plaintext highlighter-rouge">forward-sexp</code></li>
  <li><strong>F# Interactive (REPL)</strong> integration with tree-sitter highlighting for input</li>
  <li><strong>dotnet CLI integration</strong> – build, test, run, clean, format, restore, with
watch mode support</li>
  <li><strong>.NET API documentation lookup</strong> at point (<code class="language-plaintext highlighter-rouge">C-c C-d</code>)</li>
  <li><strong>Eglot integration</strong> for
<a href="https://github.com/fsharp/FsAutoComplete">FsAutoComplete</a></li>
  <li><strong>Compilation error parsing</strong> for <code class="language-plaintext highlighter-rouge">dotnet build</code> output</li>
  <li>Shift region left/right, auto-detect indent offset, prettify symbols, outline
mode, and more</li>
</ul>

<h3 id="migrating-from-fsharp-mode">Migrating from fsharp-mode</h3>

<p>If you’re currently using <code class="language-plaintext highlighter-rouge">fsharp-mode</code>, switching is straightforward:</p>

<div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre><span class="p">(</span><span class="nb">use-package</span> <span class="nv">fsharp-ts-mode</span>
  <span class="ss">:vc</span> <span class="p">(</span><span class="ss">:url</span> <span class="s">"https://github.com/bbatsov/fsharp-ts-mode"</span> <span class="ss">:rev</span> <span class="ss">:newest</span><span class="p">))</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The main thing <code class="language-plaintext highlighter-rouge">fsharp-ts-mode</code> doesn’t have yet is automatic LSP server
installation (the <code class="language-plaintext highlighter-rouge">eglot-fsharp</code> package does this for <code class="language-plaintext highlighter-rouge">fsharp-mode</code>). You’ll
need to install FsAutoComplete yourself:</p>

<pre><code class="language-shellsession">$ dotnet tool install -g fsautocomplete
</code></pre>

<p>After that, <code class="language-plaintext highlighter-rouge">(add-hook 'fsharp-ts-mode-hook #'eglot-ensure)</code> is all you need.</p>

<p>See the <a href="https://github.com/bbatsov/fsharp-ts-mode#migrating-from-fsharp-mode">migration
guide</a> in
the README for a detailed comparison.</p>

<h2 id="lessons-learned">Lessons Learned</h2>

<p>Working with the <a href="https://github.com/ionide/tree-sitter-fsharp">ionide/tree-sitter-fsharp</a>
grammar surfaced some interesting challenges compared to the OCaml grammar:</p>

<h3 id="fs-indentation-sensitive-syntax-is-tricky">F#’s indentation-sensitive syntax is tricky</h3>

<p>Unlike OCaml, where indentation is purely cosmetic, F# uses significant
whitespace (the “offside rule”). The tree-sitter grammar needs correct
indentation to parse correctly, which creates a chicken-and-egg problem: you
need a correct parse tree to indent, but you need correct indentation to
parse. For example, if you paste this unindented block:</p>

<div class="language-fsharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre><span class="k">let</span> <span class="n">f</span> <span class="n">x</span> <span class="p">=</span>
<span class="k">if</span> <span class="n">x</span> <span class="p">&gt;</span> <span class="mi">0</span> <span class="k">then</span>
<span class="n">x</span> <span class="o">+</span> <span class="mi">1</span>
<span class="k">else</span>
<span class="mi">0</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The parser can’t tell that <code class="language-plaintext highlighter-rouge">if</code> is the body of <code class="language-plaintext highlighter-rouge">f</code> or that <code class="language-plaintext highlighter-rouge">x + 1</code> belongs
to the <code class="language-plaintext highlighter-rouge">then</code> branch – it produces ERROR nodes everywhere, and
<code class="language-plaintext highlighter-rouge">indent-region</code> has nothing useful to work with. But if you’re typing the code
line by line, the parser always has enough context from preceding lines to
indent the current line correctly. This is a fundamental limitation of any
indentation-sensitive grammar.</p>

<h3 id="the-two-grammars-are-more-different-than-youd-expect">The two grammars are more different than you’d expect</h3>

<p>OCaml’s tree-sitter-ocaml-interface grammar inherits from the base grammar, so
you can share queries freely. F#’s <code class="language-plaintext highlighter-rouge">fsharp</code> and <code class="language-plaintext highlighter-rouge">fsharp_signature</code> grammars are
independent with different node types and field names for equivalent concepts.
For instance, a <code class="language-plaintext highlighter-rouge">let</code> binding is <code class="language-plaintext highlighter-rouge">function_or_value_defn</code> in the <code class="language-plaintext highlighter-rouge">.fs</code> grammar
but <code class="language-plaintext highlighter-rouge">value_definition</code> in the <code class="language-plaintext highlighter-rouge">.fsi</code> grammar. Type names use a <code class="language-plaintext highlighter-rouge">type_name:</code>
field in one grammar but not the other. Even some keyword tokens (<code class="language-plaintext highlighter-rouge">of</code>, <code class="language-plaintext highlighter-rouge">open</code>,
<code class="language-plaintext highlighter-rouge">type</code>) that work fine as query matches in <code class="language-plaintext highlighter-rouge">fsharp</code> fail at runtime in
<code class="language-plaintext highlighter-rouge">fsharp_signature</code>.</p>

<p>This forced me to split font-lock rules into shared and grammar-specific sets –
more code, more testing, more edge cases.</p>

<h3 id="script-files-are-weird">Script files are weird</h3>

<p>F# script (<code class="language-plaintext highlighter-rouge">.fsx</code>) files without a <code class="language-plaintext highlighter-rouge">module</code> declaration can mix <code class="language-plaintext highlighter-rouge">let</code> bindings
with bare expressions like <code class="language-plaintext highlighter-rouge">printfn</code>. The grammar doesn’t expect a declaration
after a bare expression at the top level, so it chains everything into nested
<code class="language-plaintext highlighter-rouge">application_expression</code> nodes:</p>

<div class="language-fsharp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre><span class="k">let</span> <span class="n">x</span> <span class="p">=</span> <span class="mi">1</span>
<span class="n">printfn</span> <span class="s2">"%d"</span> <span class="n">x</span>    <span class="c1">// bare expression</span>
<span class="k">let</span> <span class="n">y</span> <span class="p">=</span> <span class="mi">2</span>         <span class="c1">// grammar nests this under the printfn node</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>Each subsequent <code class="language-plaintext highlighter-rouge">let</code> ends up one level deeper, causing progressive
indentation. I worked around this with a heuristic that detects declarations
whose ancestor chain leads back to <code class="language-plaintext highlighter-rouge">file</code> through these misparented nodes and
forces them to column 0. Shebangs (<code class="language-plaintext highlighter-rouge">#!/usr/bin/env dotnet fsi</code>) required a
different trick – excluding the first line from the parser’s range entirely
via <code class="language-plaintext highlighter-rouge">treesit-parser-set-included-ranges</code>.</p>

<p>I’ve filed issues upstream for the grammar pain points – hopefully they’ll
improve over time.</p>

<h2 id="current-status">Current Status</h2>

<p>Let me be upfront: this is a 0.1.0 release and it’s probably quite buggy. I’ve
tested it against a reasonable set of F# code, but there are certainly
indentation edge cases, font-lock gaps, and interactions I haven’t encountered
yet. If you try it and something looks wrong, please <a href="https://github.com/bbatsov/fsharp-ts-mode/issues">open an
issue</a> – <code class="language-plaintext highlighter-rouge">M-x
fsharp-ts-mode-bug-report-info</code> will collect the environment details for you.</p>

<p>The package can currently be installed only from GitHub (via <code class="language-plaintext highlighter-rouge">package-vc-install</code>
or manually). I’ve filed a <a href="https://github.com/melpa/melpa/pull/9917">PR with
MELPA</a> and I hope it will get merged
soon.</p>

<h2 id="wrapping-up">Wrapping Up</h2>

<p>I really need to take a break from building Tree-sitter major modes at this
point. Between <code class="language-plaintext highlighter-rouge">clojure-ts-mode</code>, <code class="language-plaintext highlighter-rouge">neocaml</code>, <code class="language-plaintext highlighter-rouge">asciidoc-mode</code>, and now
<code class="language-plaintext highlighter-rouge">fsharp-ts-mode</code>, I’ve spent a lot of time staring at tree-sitter node types and
indent rules.<sup id="fnref:3"><a href="#fn:3" class="footnote" rel="footnote" role="doc-noteref">3</a></sup> It’s been fun, but I think I’ve earned a vacation from
<code class="language-plaintext highlighter-rouge">treesit-font-lock-rules</code>.</p>

<p>I really wanted to do something nice for the (admittedly small) F#-on-Emacs
community, and a modern major mode seemed like the most meaningful contribution I
could make. I hope some of you find it useful!</p>

<p>That’s all from me, folks! Keep hacking!</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1">
      <p>Way more time than I needed to actually implement the mode. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;&#xfe0e;</a></p>
    </li>
    <li id="fn:2">
      <p>Many people pointed out they thought <code class="language-plaintext highlighter-rouge">neocaml</code> was some package for neovim. Go figure why! <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;&#xfe0e;</a></p>
    </li>
    <li id="fn:3">
      <p>I’ve also been helping a bit with <a href="https://github.com/erlang/emacs-erlang-ts">erlang-ts-mode</a> recently. <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;&#xfe0e;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Bozhidar Batsov</name></author><category term="F#" /><category term="Emacs" /><summary type="html"><![CDATA[I’m pretty much done with the focused development push on neocaml – it’s reached a point where I’m genuinely happy using it daily and the remaining work is mostly incremental polish. So naturally, instead of taking a break I decided it was time to start another project that’s been living in the back of my head for a while: a proper Tree-sitter-based F# mode for Emacs.]]></summary></entry><entry><title type="html">Neocaml 0.6: Opam, Dune, and More</title><link href="https://batsov.com/articles/2026/03/25/neocaml-0-6-opam-dune-and-more/" rel="alternate" type="text/html" title="Neocaml 0.6: Opam, Dune, and More" /><published>2026-03-25T10:00:00+02:00</published><updated>2026-03-25T10:00:00+02:00</updated><id>https://batsov.com/articles/2026/03/25/neocaml-0-6-opam-dune-and-more</id><content type="html" xml:base="https://batsov.com/articles/2026/03/25/neocaml-0-6-opam-dune-and-more/"><![CDATA[<p>When I released <a href="/articles/2026/02/14/neocaml-0-1-ready-for-action/">neocaml 0.1</a>
last month, I thought I was more or less done with the (main) features for the
foreseeable future. The original scope was deliberately small — a couple of
Tree-sitter-powered OCaml major modes (for <code class="language-plaintext highlighter-rouge">.ml</code> and <code class="language-plaintext highlighter-rouge">.mli</code>), a REPL
integration, and not much else. I was quite happy with how things turned out and
figured the next steps would be mostly polish and bug fixes.</p>

<p>Versions 0.2-0.5 brought polish and bug fixes, but fundamentally the feature
set stayed the same. I was even more convinced a grand 1.0 release was just
around the corner.</p>

<p>I was wrong.</p>

<p>Of course, OCaml files don’t exist in isolation. They live alongside
<a href="https://opam.ocaml.org/">Opam</a> files that describe packages and
<a href="https://dune.readthedocs.io/">Dune</a> files that configure builds. And as I was
poking around the Tree-sitter ecosystem, I discovered that there were already
grammars for both
<a href="https://github.com/tmcgilchrist/tree-sitter-opam">Opam</a> and
<a href="https://github.com/tmcgilchrist/tree-sitter-dune">Dune</a> files. Given how
simple both formats are (Opam is mostly key-value pairs, Dune is
s-expressions), adding support for them turned out to be fairly
straightforward.</p>

<p>So here we are with neocaml 0.6, which is quite a bit bigger than I expected
originally.</p>

<p><strong>Note:</strong> One thing worth mentioning — all the new modes are completely
isolated from the core OCaml modes. They’re separate files with no hard
dependency on <code class="language-plaintext highlighter-rouge">neocaml-mode</code>, loaded only when you open the relevant file
types. I didn’t want to force them upon anyone — for me it’s convenient to get
Opam and Dune support out-of-the-box (given how ubiquitous they are in the OCaml
ecosystem), but I totally get it if someone doesn’t care about this.</p>

<p>Let me walk you through what’s new.</p>

<h2 id="neocaml-opam-mode">neocaml-opam-mode</h2>

<p>The new <code class="language-plaintext highlighter-rouge">neocaml-opam-mode</code> activates automatically for <code class="language-plaintext highlighter-rouge">.opam</code> and <code class="language-plaintext highlighter-rouge">opam</code>
files. It provides:</p>

<ul>
  <li>Tree-sitter-based font-lock (field names, strings, operators, version
constraints, filter expressions, etc.)</li>
  <li>Indentation (lists, sections, option braces)</li>
  <li>Imenu for navigating variables and sections</li>
  <li>A <strong>flymake backend</strong> that runs <code class="language-plaintext highlighter-rouge">opam lint</code> on the current buffer, giving you
inline diagnostics for missing fields, deprecated constructs, and syntax
errors</li>
</ul>

<p>The flymake backend registers automatically when <code class="language-plaintext highlighter-rouge">opam</code> is found in your PATH,
but you need to enable <code class="language-plaintext highlighter-rouge">flymake-mode</code> yourself:</p>

<div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre><span class="p">(</span><span class="nv">add-hook</span> <span class="ss">'neocaml-opam-mode-hook</span> <span class="nf">#'</span><span class="nv">flymake-mode</span><span class="p">)</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p><a href="https://github.com/flycheck/flycheck">Flycheck</a> users get <code class="language-plaintext highlighter-rouge">opam lint</code> support
out of the box via Flycheck’s built-in <code class="language-plaintext highlighter-rouge">opam</code> checker — no extra configuration
needed.</p>

<p>This bridges some of the gap with <a href="https://github.com/ocaml/tuareg">Tuareg</a>,
which also bundles an Opam major mode (<code class="language-plaintext highlighter-rouge">tuareg-opam-mode</code>). The Tree-sitter-based
approach gives us more accurate highlighting, and the flymake integration is a
nice bonus on top.</p>

<h2 id="neocaml-dune-mode">neocaml-dune-mode</h2>

<p><code class="language-plaintext highlighter-rouge">neocaml-dune-mode</code> handles <code class="language-plaintext highlighter-rouge">dune</code>, <code class="language-plaintext highlighter-rouge">dune-project</code>, and <code class="language-plaintext highlighter-rouge">dune-workspace</code> files
— all three use the same s-expression syntax and share a single Tree-sitter
grammar. You get:</p>

<ul>
  <li>Font-lock for stanza names, field names, action keywords, strings, module
names, library names, operators, and brackets</li>
  <li>Indentation with 1-space offset</li>
  <li>Imenu for stanza navigation</li>
  <li>Defun navigation and <code class="language-plaintext highlighter-rouge">which-func</code> support</li>
</ul>

<p>This removes the need to install the separate
<a href="https://melpa.org/#/dune">dune</a> package (the standalone <code class="language-plaintext highlighter-rouge">dune-mode</code> maintained
by the Dune developers) from MELPA. If you prefer to keep using it, that’s fine
too — neocaml’s README has
<a href="https://github.com/bbatsov/neocaml#using-the-legacy-dune-mode">instructions</a>
for overriding the <code class="language-plaintext highlighter-rouge">auto-mode-alist</code> entries.</p>

<h2 id="neocaml-dune-interaction-mode">neocaml-dune-interaction-mode</h2>

<p>Beyond editing Dune files, I wanted a simple way to run Dune commands from any
neocaml buffer. <code class="language-plaintext highlighter-rouge">neocaml-dune-interaction-mode</code> is a minor mode that provides
keybindings (under <code class="language-plaintext highlighter-rouge">C-c C-d</code>) and a “Dune” menu for common operations:</p>

<table>
  <thead>
    <tr>
      <th>Keybinding</th>
      <th>Command</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C-c C-d b</code></td>
      <td>Build</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C-c C-d t</code></td>
      <td>Test</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C-c C-d c</code></td>
      <td>Clean</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C-c C-d p</code></td>
      <td>Promote</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C-c C-d f</code></td>
      <td>Format</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C-c C-d u</code></td>
      <td>Launch utop with project libraries</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C-c C-d r</code></td>
      <td>Run an executable</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C-c C-d d</code></td>
      <td>Run any Dune command</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">C-c C-d .</code></td>
      <td>Find the nearest <code class="language-plaintext highlighter-rouge">dune</code> file</td>
    </tr>
  </tbody>
</table>

<p>All commands run via Emacs’s <code class="language-plaintext highlighter-rouge">compile</code>, so you get error navigation, clickable
source locations, and the full <code class="language-plaintext highlighter-rouge">compilation-mode</code> interface for free. With a
prefix argument (<code class="language-plaintext highlighter-rouge">C-u</code>), build, test, and fmt run in <strong>watch mode</strong>
(<code class="language-plaintext highlighter-rouge">--watch</code>), automatically rebuilding when files change.</p>

<p>The <code class="language-plaintext highlighter-rouge">utop</code> command is special — it launches through <code class="language-plaintext highlighter-rouge">neocaml-repl</code>, so you get
the full REPL integration (send region, send definition, etc.) with your
project’s libraries preloaded.</p>

<p>This mode is completely independent from <code class="language-plaintext highlighter-rouge">neocaml-dune-mode</code> — it doesn’t care
which major mode you’re using. You can enable it in OCaml buffers like this:</p>

<div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre><span class="p">(</span><span class="nv">add-hook</span> <span class="ss">'neocaml-base-mode-hook</span> <span class="nf">#'</span><span class="nv">neocaml-dune-interaction-mode</span><span class="p">)</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<h2 id="rough-edges">Rough Edges</h2>

<p>Both the Opam and Dune Tree-sitter grammars are relatively young and will need
some more work for optimal results. I’ve been filing issues and contributing
patches upstream to improve them — for instance, the Dune grammar currently
<a href="https://github.com/tmcgilchrist/tree-sitter-dune/issues/9">flattens field-value
pairs</a> in a way
that makes indentation less precise than it could be, and neither grammar
supports <a href="https://github.com/tmcgilchrist/tree-sitter-dune/issues/10">variable
interpolation</a>
(<code class="language-plaintext highlighter-rouge">%{...}</code>) yet. These are very solvable problems and I expect the grammars to
improve over time.</p>

<h2 id="whats-next">What’s Next?</h2>

<p>At this point I think I’m (finally!) out of ideas for new functionality. This
time I mean it! Neocaml now covers pretty much everything I ever wanted,
especially when paired with the awesome
<a href="https://github.com/tarides/ocaml-eglot">ocaml-eglot</a>.</p>

<p>Down the road there might be support for OCamllex (<code class="language-plaintext highlighter-rouge">.mll</code>) or Menhir (<code class="language-plaintext highlighter-rouge">.mly</code>)
files, but only if adding them doesn’t bring significant complexity — both are
mixed languages with embedded OCaml code, which makes them fundamentally harder
to support well than the simple Opam and Dune formats.</p>

<p>I hope OCaml programmers will find the new functionality useful. If you’re using neocaml,
I’d love to hear how it’s working for you — bug reports, feature requests, and
general feedback are all welcome on
<a href="https://github.com/bbatsov/neocaml">GitHub</a>. You can find the full list of
changes in the
<a href="https://github.com/bbatsov/neocaml/blob/main/CHANGELOG.md">changelog</a>.</p>

<p>As usual — update from <a href="https://melpa.org/#/neocaml">MELPA</a>, kick the tires,
and let me know what you think.</p>

<p>That’s all I have for you today! Keep hacking!</p>]]></content><author><name>Bozhidar Batsov</name></author><category term="OCaml" /><category term="Emacs" /><summary type="html"><![CDATA[When I released neocaml 0.1 last month, I thought I was more or less done with the (main) features for the foreseeable future. The original scope was deliberately small — a couple of Tree-sitter-powered OCaml major modes (for .ml and .mli), a REPL integration, and not much else. I was quite happy with how things turned out and figured the next steps would be mostly polish and bug fixes.]]></summary></entry><entry><title type="html">Emacs and Vim in the Age of AI</title><link href="https://batsov.com/articles/2026/03/09/emacs-and-vim-in-the-age-of-ai/" rel="alternate" type="text/html" title="Emacs and Vim in the Age of AI" /><published>2026-03-09T10:30:00+02:00</published><updated>2026-03-09T10:30:00+02:00</updated><id>https://batsov.com/articles/2026/03/09/emacs-and-vim-in-the-age-of-ai</id><content type="html" xml:base="https://batsov.com/articles/2026/03/09/emacs-and-vim-in-the-age-of-ai/"><![CDATA[<blockquote>
  <p>It’s tough to make predictions, especially about the future.</p>

  <p>– Yogi Berra</p>
</blockquote>

<p>I’ve been an Emacs fanatic for over 20 years. I’ve built and maintained some of
the most popular Emacs packages, contributed to Emacs itself, and spent
countless hours tweaking my configuration. Emacs isn’t just my editor – it’s
my passion, and my happy place.</p>

<p>Over the past year, I’ve also been spending a lot of time with Vim and Neovim,
relearning them from scratch and having a blast contrasting how the two
communities approach similar problems. It’s been a fun and refreshing
experience.<sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup></p>

<p>And lately, like everyone else in our industry, I’ve been playing with AI tools
– <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a> in particular
– watching the impact of AI on the broader programming landscape, and
pondering what it all means for the future of programming. Naturally, I keep
coming back to the same question: what happens to my beloved Emacs and its
“arch nemesis” Vim in this brave new world?</p>

<p>I think the answer is more nuanced than either “they’re doomed” or “nothing
changes”. Predicting the future is hard, but speculating is irresistible.</p>

<!--more-->

<h2 id="the-risks">The Risks</h2>

<blockquote>
  <p>The only thing that is constant is change.</p>

  <p>– Heraclitus</p>
</blockquote>

<p>Things are rarely black and white – usually just some shade of gray. AI is
obviously no exception, and I think it’s worth examining both sides honestly
before drawing any conclusions.</p>

<p>Let’s start with the challenges.</p>

<h3 id="the-ide-gravity-well">The IDE gravity well</h3>

<p>VS Code is already the dominant editor by a wide margin, and it’s going to get
first-class integrations with every major AI tool – Copilot (obviously),
Codex, Claude, Gemini, you name it. Microsoft has every incentive to make VS
Code the best possible host for AI-assisted development, and the resources to
do it.</p>

<p>On top of that, purpose-built AI editors like <a href="https://cursor.com">Cursor</a>,
<a href="https://windsurf.com">Windsurf</a>, and others are attracting serious investment
and talent. These aren’t adding AI to an existing editor as an afterthought –
they’re building the entire experience around AI workflows. They offer
integrated context management, inline diffs, multi-file editing, and agent
loops that feel native rather than bolted on.</p>

<p>Every developer who switches to one of these tools is a developer who isn’t
learning Emacs or Vim keybindings, isn’t writing Elisp, and isn’t contributing
to our ecosystems. The gravity well is real.</p>

<blockquote class="prompt-info">
  <p>I never tried Cursor and Windsurf simply because they are essentially
forks of VS Code and I can’t stand VS Code. I’ve tried it several times over
the years and I never felt productive in it for a variety of reasons.</p>
</blockquote>

<h3 id="do-you-even-need-a-power-tool-anymore">Do you even need a “power tool” anymore?</h3>

<p>Part of the case for Emacs and Vim has always been that they make you faster at
writing and editing code. The keybindings, the macros, the extensibility – all
of it is in service of making the human more efficient at the mechanical act of
coding.</p>

<p>But if AI is writing most of your code, how much does mechanical editing speed
matter? When you’re reviewing and steering AI-generated diffs rather than
typing code character by character, the bottleneck shifts from “how fast can I
edit” to “how well can I specify intent and evaluate output.” That’s a
fundamentally different skill, and it’s not clear that Emacs or Vim have an
inherent advantage there.</p>

<p>The learning curve argument gets harder to justify too. “Spend six months
learning Emacs and you’ll be 10x faster” is a tough sell when a junior
developer with Cursor can scaffold an entire application in an afternoon.<sup id="fnref:2"><a href="#fn:2" class="footnote" rel="footnote" role="doc-noteref">2</a></sup>
Then again, maybe the question of <em>what</em> exactly you’re editing deserves a
closer look.</p>

<h3 id="the-corporate-backing-asymmetry">The corporate backing asymmetry</h3>

<p>VS Code has Microsoft. Cursor has venture capital. Emacs has… a small group
of volunteers and the FSF. Vim had Bram, and now has a community of
maintainers. Neovim has a small but dedicated core team.</p>

<p>This has always been the case, of course, but AI amplifies the gap. Building
deep AI integrations requires keeping up with fast-moving APIs, models, and
paradigms. Well-funded teams can dedicate engineers to this full-time.
Volunteer-driven projects move at the pace of people’s spare time and
enthusiasm.</p>

<h3 id="the-doomsday-scenario">The doomsday scenario</h3>

<p>Let’s go all the way: what if programming as we know it is fully automated
within the next decade? If AI agents can take a specification and produce
working, tested, deployed software without human intervention, we won’t need
coding editors at all. Not Emacs, not Vim, not VS Code, not Cursor. The entire
category becomes irrelevant.</p>

<p>I don’t think this is likely in the near term, but it’s worth acknowledging as
a possibility. The trajectory of AI capabilities has surprised even the
optimists (and I was initially an AI skeptic, but the rapid advancements last
year eventually changed my mind).</p>

<h2 id="the-opportunities">The Opportunities</h2>

<p>That paints a grim picture, but Emacs and Vim have been
written off more times than I can count. Eclipse was going to kill them.
IntelliJ was going to kill them. VS Code was going to kill them. Sublime Text,
Atom, TextMate – all were supposedly the final nail in the coffin. Most of
those “killers” are themselves dead or declining, while Emacs and Vim keep
chugging along. There’s a resilience to these editors that’s easy to
underestimate.</p>

<p>So let’s look at the other side of the coin.</p>

<h3 id="ai-helps-you-help-yourself">AI helps you help yourself</h3>

<p>One of the most underappreciated benefits of AI for Emacs and Vim users is
mundane: troubleshooting. Both editors have notoriously steep learning curves
and opaque error messages. “Wrong type argument: stringp, nil” has driven more
people away from Emacs than any competitor ever did.</p>

<p>AI tools are remarkably good at explaining cryptic error messages, diagnosing
configuration issues, and suggesting fixes. They can read your init file and
spot the problem. They can explain what a piece of Elisp does. They can help
you understand why your keybinding isn’t working. This dramatically flattens
the learning curve – not by making the editor simpler, but by giving every
user access to a patient, knowledgeable guide.</p>

<p>I don’t really need any AI assistance to troubleshoot anything in my Emacs setup,
but it’s been handy occasionally in Neovim-land, where my knowledge is
relatively modest by comparison.</p>

<blockquote class="prompt-tip">
  <p>There’s at least one documented case of someone
<a href="https://www.reddit.com/r/emacs/comments/1ri5g5e/back_home_at_last_thanks_to_claude_code/">returning to Emacs after years away</a>,
specifically because Claude Code made it painless to fix configuration issues.
They’d left for IntelliJ because the configuration burden got too annoying –
and came back once AI removed that barrier. “Happy f*cking days I’m home
again,” as they put it. If AI can bring back lapsed Emacs users, that’s a
good thing in my book.</p>
</blockquote>

<h3 id="ai-makes-configuration-and-extension-trivial">AI makes configuration and extension trivial</h3>

<p>Something that doesn’t get nearly enough attention: Emacs and Vim have always
suffered from the obscurity of their extension languages. Emacs Lisp is a
1980s Lisp dialect that most programmers have never seen before. VimScript is…
VimScript. Even Lua, which Neovim adopted specifically because it’s more
approachable, is niche enough that most developers haven’t written a line of it.</p>

<p>This has been the single biggest bottleneck for both ecosystems. Not the
editors themselves – they’re incredibly powerful – but the fact that
customizing them requires learning an unfamiliar language, and most people never
make it past copying snippets from blog posts and READMEs.</p>

<p>I felt incredibly overwhelmed by Elisp and VimScript when I was learning Emacs and
Vim for the first time, and I imagine I wasn’t the only one. I started to feel
very productive in Emacs only after putting in quite a lot of time to actually
learn Elisp properly. (Never bothered to do the same for VimScript, though,
and I’m not too eager to master Lua either.)</p>

<p>AI changes this overnight. You can now describe what you want in plain English
and get working Elisp, VimScript, or Lua. “Write me an Emacs function that
reformats the current paragraph to 72 columns and adds a prefix” – done.
“Configure lazy.nvim to set up LSP with these keybindings” – done. The
extension language barrier, which has been the biggest obstacle to adoption for
decades, is suddenly much lower.</p>

<h3 id="the-same-goes-for-plugin-development">The same goes for plugin development</h3>

<p>After 20+ years in the Emacs community, I often have the feeling that a
relatively small group – maybe 50 to 100 people – is driving most of the
meaningful progress. The same names show up in MELPA, on the mailing lists, and
in bug reports. This isn’t a criticism of those people (I’m proud to be among
them), but it’s a structural weakness. A community that depends on so few
contributors is fragile.</p>

<p>And it’s not just Elisp and VimScript. The C internals of both Emacs and Vim
(and Neovim’s C core) are maintained by an even smaller group. Finding people
who are both willing and able to hack on decades-old C codebases is genuinely
hard, and it’s only getting harder as fewer developers learn C at all.</p>

<p>AI tools can help here in two ways. First, they lower the barrier for new
contributors – someone who understands the <em>concept</em> of what they want to
build can now get AI assistance with the <em>implementation</em> in an unfamiliar
language. Second, they help existing maintainers move faster. I’ve personally
found that AI is excellent at generating test scaffolding, writing
documentation, and handling the tedious parts of package maintenance that slow
everything down.</p>

<h3 id="ai-integrations-are-already-happening">AI integrations are already happening</h3>

<p>The Emacs and Neovim communities aren’t sitting idle. There are already
impressive AI integrations:</p>

<p><strong>Emacs:</strong></p>

<ul>
  <li><a href="https://github.com/karthink/gptel">gptel</a> – a versatile LLM client that
supports multiple backends (Claude, GPT, Gemini, local models)</li>
  <li><a href="https://github.com/s-kostyaev/ellama">ellama</a> – an Emacs interface for
interacting with LLMs via llama.cpp and Ollama</li>
  <li><a href="https://github.com/tninja/aider.el">aider.el</a> – Emacs integration for
<a href="https://aider.chat">Aider</a>, the popular AI pair programming tool</li>
  <li><a href="https://github.com/copilot-emacs/copilot.el">copilot.el</a> – GitHub Copilot
integration (I happen to be the current maintainer of the project)</li>
  <li><a href="https://github.com/lanceberge/elysium">elysium</a> – an AI-powered coding
assistant with inline diff application</li>
  <li><a href="https://github.com/xenodium/agent-shell">agent-shell</a> – a native Emacs
buffer for interacting with LLM agents (Claude Code, Gemini CLI, etc.)
via the <a href="https://agentclientprotocol.org/">Agent Client Protocol</a></li>
  <li><a href="https://eca.dev/">ECA</a> – an editor-agnostic AI coding assistant (built in
Clojure!) that works via a protocol similar to LSP, supporting multiple LLM
providers (and Neovim too)</li>
</ul>

<p><strong>Neovim:</strong></p>

<ul>
  <li><a href="https://github.com/yetone/avante.nvim">avante.nvim</a> – a Cursor-like AI
coding experience inside Neovim</li>
  <li><a href="https://github.com/olimorris/codecompanion.nvim">codecompanion.nvim</a> – a
Copilot Chat replacement supporting multiple LLM providers</li>
  <li><a href="https://github.com/zbirenbaum/copilot.lua">copilot.lua</a> – native Copilot
integration for Neovim</li>
  <li><a href="https://github.com/Robitx/gp.nvim">gp.nvim</a> – ChatGPT-like sessions in
Neovim with support for multiple providers</li>
</ul>

<p>And this is just a sample. Building these integrations isn’t as hard as it
might seem – the APIs are straightforward, and the extensibility of both
editors means you can wire up AI tools in ways that feel native. With AI
assistance, creating new integrations becomes even easier. I wouldn’t be
surprised if the pace of plugin development accelerates significantly.</p>

<h3 id="terminal-native-ai-tools-are-a-natural-fit">Terminal-native AI tools are a natural fit</h3>

<p>Funny enough, many of the most powerful AI
coding tools are terminal-native. Claude Code, Aider, and various Copilot CLI
tools all run in the terminal. And what lives in the terminal? Emacs and Vim.<sup id="fnref:3"><a href="#fn:3" class="footnote" rel="footnote" role="doc-noteref">3</a></sup></p>

<p>Running Claude Code in an Emacs <code class="language-plaintext highlighter-rouge">vterm</code> buffer or a Neovim terminal split is
a perfectly natural workflow. You get the AI agent in one pane and your editor
in another, with all your keybindings and tools intact. There’s no context
switching to a different application – it’s all in the same environment.</p>

<p>This is actually an advantage over GUI-based AI editors, where the AI
integration is tightly coupled to the editor’s own interface. With
terminal-native tools, you get to choose your own editor <em>and</em> your own AI
tool, and they compose naturally.</p>

<blockquote class="prompt-info">
  <p>There’s another angle worth considering: if programming is increasingly about
writing prompts rather than code, you still benefit from a great text editor
for <em>that</em>. Prompts are text, and crafting them well matters. I find it ironic
that Claude Code – a tool I otherwise love – doesn’t use readline, so my
Emacs keybindings don’t work properly in it, and its vim emulation is fairly
poor. I still think using React for CLI apps is a mistake, and I suspect many
people would enjoy running Claude Code inside their Emacs or Vim instead.
That’s exactly what the <a href="https://agentclientprotocol.org/">Agent Client Protocol</a>
(ACP) enables – it lets editors like Emacs (via
<a href="https://github.com/xenodium/agent-shell">agent-shell</a>) act as first-class
clients for AI agents, giving you proper editing, keybindings, and all the
power of your editor while interacting with tools like Claude Code. The
best prompt editor might just be the one you’ve been using for decades.</p>
</blockquote>

<h3 id="emacs-as-an-ai-integration-platform">Emacs as an AI integration platform</h3>

<p>Emacs’s “editor as operating system” philosophy is uniquely well-suited to AI
integration. It’s not just a code editor – it’s a mail client (Gnus, mu4e),
a note-taking system (Org mode), a Git interface (Magit), a terminal emulator,
a file manager, an RSS reader, and much more.</p>

<p>AI can be integrated at every one of these layers. Imagine an AI assistant that
can read your org-mode agenda, draft email replies in mu4e, help you write
commit messages in Magit, and refactor code in your source buffers – all
within the same environment, sharing context. No other editor architecture
makes this kind of deep, cross-domain integration as natural as Emacs does.</p>

<p>I stopped using Emacs as my OS a long time ago, and these days
I use it mostly for programming and blogging. (I’m writing this article in Emacs with
the help of <code class="language-plaintext highlighter-rouge">markdown-mode</code>.) Still, I’m only one Emacs user and many are probably
using it in a more holistic manner.</p>

<h3 id="even-in-the-post-coding-apocalypse-emacs-and-vim-survive">Even in the post-coding apocalypse, Emacs and Vim survive</h3>

<p>Let’s revisit the doomsday scenario. Say programming is fully automated and
nobody writes code anymore. Does Emacs die?</p>

<p>Not necessarily. Emacs is already used for far more than programming. People
use Org mode to manage their entire lives – tasks, notes, calendars,
journals, time tracking, even academic papers. Emacs is a capable writing
environment for prose, with excellent support for LaTeX, Markdown, AsciiDoc,
and plain text. You can read email, browse the web, manage files, and yes,
play Tetris.</p>

<p>Vim, similarly, is a text editing paradigm as much as a program. Vim
keybindings have colonized every text input in the computing world – VS Code,
IntelliJ, browsers, shells, even Emacs (via Evil mode). Even if the Vim
<em>program</em> fades, the Vim <em>idea</em> is immortal.<sup id="fnref:4"><a href="#fn:4" class="footnote" rel="footnote" role="doc-noteref">4</a></sup></p>

<p>And who knows – maybe there’ll be a market for artisanal, hand-crafted
software one day, the way there’s a market for vinyl records and mechanical
watches.<sup id="fnref:6"><a href="#fn:6" class="footnote" rel="footnote" role="doc-noteref">5</a></sup> “Organic, small-batch code, lovingly typed by a human in Emacs –
one character at a time.” I’d buy that t-shirt. And I’m fairly certain those
artisan programmers won’t be using VS Code.</p>

<p>So even in the most extreme scenario, both editors have a life beyond code.
A diminished one, perhaps, but a life nonetheless.</p>

<h2 id="the-bigger-picture">The Bigger Picture</h2>

<p>I think what’s actually happening is more interesting than “editors die” or
“editors are fine.” The role of the editor is shifting.</p>

<p>For decades, the editor was where you <em>wrote</em> code. Increasingly, it’s
becoming where you <em>review, steer, and refine</em> code that AI writes. The
skills that matter are shifting from typing speed and editing gymnastics to
specification clarity, code reading, and architectural judgment.</p>

<p>In this world, the editor that wins isn’t the one with the best code
completion – it’s the one that gives you the most control over your workflow.
And that has always been Emacs and Vim’s core value proposition.</p>

<p>The question is whether the communities can adapt fast enough. The tools are
there. The architecture is there. The philosophy is right. What’s needed is
people – more contributors, more plugin authors, more documentation writers,
more voices in the conversation. AI can help bridge the gap, but it can’t
replace genuine community engagement.</p>

<h2 id="the-ethical-elephant-in-the-room">The Ethical Elephant in the Room</h2>

<p>Not everyone in the Emacs and Vim communities is enthusiastic about AI, and
the objections go beyond mere technophobia. There are legitimate ethical
concerns that are going to be debated for a long time:</p>

<ul>
  <li>
    <p><strong>Energy consumption.</strong> Training and running large language models requires
enormous amounts of compute and electricity. For communities that have long
valued efficiency and minimalism – Emacs users who pride themselves on running
a 40-year-old editor, Vim users who boast about their sub-second startup times
– the environmental cost of AI is hard to ignore.</p>
  </li>
  <li>
    <p><strong>Copyright and training data.</strong> LLMs are trained on vast corpora of code and
text, and the legality and ethics of that training remain contested. Some
developers are uncomfortable using tools that may have learned from
copyrighted code without explicit consent. This concern hits close to home
for open-source communities that care deeply about licensing.</p>
  </li>
  <li>
    <p><strong>Job displacement.</strong> If AI makes developers significantly more productive,
fewer developers might be needed. This is an uncomfortable thought for any
programming community, and it’s especially pointed for editors whose identity
is built around empowering <em>human</em> programmers.</p>
  </li>
</ul>

<p>These concerns are already producing concrete action. The Vim community recently
saw the creation of <a href="https://codeberg.org/NerdNextDoor/evi">EVi</a>, a fork of Vim
whose entire raison d’etre is to provide a text editor free from AI-assisted
(generated?) code contributions.<sup id="fnref:5"><a href="#fn:5" class="footnote" rel="footnote" role="doc-noteref">6</a></sup> Whether you agree with the premise or not, the
fact that people are forking established editors over this tells you how
strongly some community members feel.</p>

<p>I don’t think these concerns should stop anyone from exploring AI tools, but
they’re real and worth taking seriously. I expect to see plenty of spirited
debate about this on emacs-devel and the Neovim issue tracker in the years
ahead.</p>

<h2 id="closing-thoughts">Closing Thoughts</h2>

<blockquote>
  <p>The future ain’t what it used to be.</p>

  <p>– Yogi Berra</p>
</blockquote>

<p>I won’t pretend I’m not worried. The AI wave is moving fast, the incumbents
have massive advantages in funding and mindshare, and the very nature of
programming is shifting under our feet. It’s entirely possible that Emacs and
Vim will gradually fade into niche obscurity, used only by a handful of
diehards who refuse to move on.</p>

<p>What keeps these editors alive isn’t stubbornness – it’s adaptability. The
communities are small but passionate, the editors are more capable than ever,
and the architecture is genuinely well-suited to the AI era. Vim’s core idea
is so powerful that it keeps finding new expression (Neovim being the most
vigorous one). And Emacs? Emacs just keeps absorbing whatever the world throws
at it. It always has.</p>

<p>The editors that survive won’t be the ones with the flashiest AI features. They’ll
be the ones whose users care enough to keep building, adapting, and sharing.
That’s always been the real engine of open-source software, and no amount of
AI changes that.</p>

<p>So if you’re an Emacs or Vim user: don’t panic, but don’t be complacent
either. Learn the new AI tools (if you’re not fundamentally opposed to them,
that is). Pimp your setup and make it awesome. Write about your
workflows. Help newcomers. The best way to ensure your editor survives the AI
age is to make it thrive in it.</p>

<p>Maybe the future ain’t what it used to be – but that’s not necessarily a bad
thing.</p>

<p>This essay covered more ground than I originally intended – lots of thoughts
have been rattling around in my head for a while now, and I wanted to get them
all out. Programming may be hard, but writing prose remains harder. Still, I
hope some of these ideas resonated with you.</p>

<p>That’s all I have for you today. Keep hacking!</p>

<p><strong>P.S.</strong> There’s an interesting <a href="https://news.ycombinator.com/item?id=47319071">Hacker News discussion</a> about this article. Check it out if you want to see what the broader community thinks!</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1">
      <p>If you’re curious about my Vim adventures, I wrote about them in <a href="/articles/2026/02/24/learning-vim-in-3-steps/">Learning Vim in 3 Steps</a>. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;&#xfe0e;</a></p>
    </li>
    <li id="fn:2">
      <p>Not to mention you’ll probably have to put in several years in Emacs before you’re actually more productive than you were with your old editor/IDE of choice. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;&#xfe0e;</a></p>
    </li>
    <li id="fn:3">
      <p>At least some of the time. Admittedly I usually use Emacs in GUI mode, but I always use (Neo)vim in the terminal. <a href="#fnref:3" class="reversefootnote" role="doc-backlink">&#8617;&#xfe0e;</a></p>
    </li>
    <li id="fn:4">
      <p>Even Claude Code has vim mode. <a href="#fnref:4" class="reversefootnote" role="doc-backlink">&#8617;&#xfe0e;</a></p>
    </li>
    <li id="fn:6">
      <p>I’m a big fan of mechanical watches myself, so I might be biased here. There’s something deeply satisfying about a beautifully crafted mechanism that doesn’t need a battery or an internet connection to work. <a href="#fnref:6" class="reversefootnote" role="doc-backlink">&#8617;&#xfe0e;</a></p>
    </li>
    <li id="fn:5">
      <p>See <a href="https://codeberg.org/NerdNextDoor/evi/issues/1">https://codeberg.org/NerdNextDoor/evi/issues/1</a>. <a href="#fnref:5" class="reversefootnote" role="doc-backlink">&#8617;&#xfe0e;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Bozhidar Batsov</name></author><category term="Emacs" /><category term="Vim" /><category term="AI" /><summary type="html"><![CDATA[It’s tough to make predictions, especially about the future. – Yogi Berra]]></summary></entry><entry><title type="html">Building Emacs Major Modes with Tree-sitter: Lessons Learned</title><link href="https://batsov.com/articles/2026/02/27/building-emacs-major-modes-with-treesitter-lessons-learned/" rel="alternate" type="text/html" title="Building Emacs Major Modes with Tree-sitter: Lessons Learned" /><published>2026-02-27T10:00:00+02:00</published><updated>2026-02-27T10:00:00+02:00</updated><id>https://batsov.com/articles/2026/02/27/building-emacs-major-modes-with-treesitter-lessons-learned</id><content type="html" xml:base="https://batsov.com/articles/2026/02/27/building-emacs-major-modes-with-treesitter-lessons-learned/"><![CDATA[<p>Over the past year I’ve been spending a lot of time building <a href="https://tree-sitter.github.io/tree-sitter/">Tree-sitter</a>-powered
major modes for Emacs – <a href="https://github.com/clojure-emacs/clojure-ts-mode">clojure-ts-mode</a>
(as co-maintainer), <a href="https://github.com/bbatsov/neocaml">neocaml</a> (from scratch),
and <a href="https://github.com/bbatsov/asciidoc-mode">asciidoc-mode</a> (also from scratch).
Between the three projects I’ve accumulated enough knowledge (and battle scars) to write about the
experience. This post distills the key lessons for anyone thinking about writing
a Tree-sitter-based major mode, or curious about what it’s actually like.</p>

<!--more-->

<h2 id="why-tree-sitter">Why Tree-sitter?</h2>

<p>Before Tree-sitter, Emacs font-locking was done with regular expressions and
indentation was handled by ad-hoc engines (SMIE, custom indent functions, or
pure regex heuristics). This works, but it has well-known problems:</p>

<ul>
  <li>
    <p><strong>Regex-based font-locking is fragile.</strong> Regexes can’t parse nested structures,
so they either under-match (missing valid code) or over-match (highlighting
inside strings and comments). Every edge case is another regex, and the patterns
become increasingly unreadable over time.</p>
  </li>
  <li>
    <p><strong>Indentation engines are complex.</strong> SMIE (the generic indentation engine for non-Tree-sitter modes) requires defining operator
precedence grammars for the language, which is hard to get right. Custom
indentation functions tend to grow into large, brittle state machines. Tuareg’s
indentation code, for example, is thousands of lines long.</p>
  </li>
</ul>

<p>Tree-sitter changes the game because you get a <strong>full, incremental, error-tolerant
syntax tree</strong> for free. Font-locking becomes “match this AST pattern, apply this
face”:</p>

<div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre><span class="c1">;; Highlight let-bound functions: match a let_binding with parameters</span>
<span class="p">(</span><span class="nv">let_binding</span> <span class="nv">pattern:</span> <span class="p">(</span><span class="nv">value_name</span><span class="p">)</span> <span class="nv">@font-lock-function-name-face</span>
             <span class="p">(</span><span class="nv">parameter</span><span class="p">)</span><span class="nb">+</span><span class="p">)</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>And indentation becomes “if the parent node is X, indent by Y”:</p>

<div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre><span class="c1">;; Children of a let_binding are indented by neocaml-indent-offset</span>
<span class="p">((</span><span class="nv">parent-is</span> <span class="s">"let_binding"</span><span class="p">)</span> <span class="nv">parent-bol</span> <span class="nv">neocaml-indent-offset</span><span class="p">)</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The rules are declarative, composable, and much easier to reason about than
regex chains.</p>

<p>In practice, <code class="language-plaintext highlighter-rouge">neocaml</code>’s entire font-lock and indentation logic fits in about 350
lines of Elisp. The equivalent in tuareg is spread across thousands of lines.
That’s the real selling point: <strong>simpler, more maintainable code that handles more
edge cases correctly</strong>.</p>

<h2 id="challenges">Challenges</h2>

<p>That said, Tree-sitter in Emacs is not a silver bullet. Here’s what I ran into.</p>

<h3 id="every-grammar-is-different">Every grammar is different</h3>

<p>Tree-sitter grammars are written by different authors with different philosophies.
The <a href="https://github.com/tree-sitter/tree-sitter-ocaml">tree-sitter-ocaml</a>
grammar provides a rich, detailed AST with named fields. The
<a href="https://github.com/sogaiu/tree-sitter-clojure">tree-sitter-clojure</a> grammar,
by contrast, deliberately keeps things minimal – it only models syntax, not
semantics, because Clojure’s macro system makes static semantic analysis
unreliable.<sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup> This means font-locking <code class="language-plaintext highlighter-rouge">def</code> forms in Clojure requires
predicate matching on symbol text, while in OCaml you can directly match
<code class="language-plaintext highlighter-rouge">let_binding</code> nodes with named fields.</p>

<p>To illustrate: here’s how you’d fontify a function definition in OCaml, where
the grammar gives you rich named fields:</p>

<div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre><span class="c1">;; OCaml: grammar provides named fields -- direct structural match</span>
<span class="p">(</span><span class="nv">let_binding</span> <span class="nv">pattern:</span> <span class="p">(</span><span class="nv">value_name</span><span class="p">)</span> <span class="nv">@font-lock-function-name-face</span>
             <span class="p">(</span><span class="nv">parameter</span><span class="p">)</span><span class="nb">+</span><span class="p">)</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>And here’s the equivalent in Clojure, where the grammar only gives you lists of
symbols and you need predicate matching:</p>

<div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre><span class="c1">;; Clojure: grammar is syntax-only -- match by symbol text</span>
<span class="p">((</span><span class="nv">list_lit</span> <span class="ss">:anchor</span> <span class="p">(</span><span class="nv">sym_lit</span> <span class="nv">!namespace</span>
                            <span class="nv">name:</span> <span class="p">(</span><span class="nv">sym_name</span><span class="p">)</span> <span class="nv">@font-lock-keyword-face</span><span class="p">))</span>
 <span class="p">(</span><span class="ss">:match</span> <span class="o">,</span><span class="nv">clojure-ts--definition-keyword-regexp</span> <span class="nv">@font-lock-keyword-face</span><span class="p">))</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>You can’t learn “how to write Tree-sitter queries” generically – you need to
learn each grammar individually. The best tool for this is <code class="language-plaintext highlighter-rouge">treesit-explore-mode</code>
(to visualize the full parse tree) and <code class="language-plaintext highlighter-rouge">treesit-inspect-mode</code> (to see the node
at point). Use them constantly.</p>

<h3 id="grammar-quality-varies-wildly">Grammar quality varies wildly</h3>

<p>You’re dependent on someone else providing the grammar, and quality is all over
the map. The OCaml grammar is mature and well-maintained – it’s hosted under the
official <a href="https://github.com/tree-sitter">tree-sitter</a> GitHub org. The Clojure
grammar is small and stable by design. But not every language is so lucky.</p>

<p><a href="https://github.com/bbatsov/asciidoc-mode">asciidoc-mode</a> uses a
<a href="https://github.com/cathaysia/tree-sitter-asciidoc">third-party AsciiDoc grammar</a>
that employs a dual-parser architecture – one parser for block-level structure
(headings, lists, code blocks) and another for inline formatting (bold, italic,
links). This is the same approach used by Emacs’s built-in <code class="language-plaintext highlighter-rouge">markdown-ts-mode</code>,
and it makes sense for markup languages where block and inline syntax are largely
independent.</p>

<p>The problem is that the two parsers run independently on the same text, and they
can <strong>disagree</strong>. The inline parser misinterprets <code class="language-plaintext highlighter-rouge">*</code> and <code class="language-plaintext highlighter-rouge">**</code> list markers as
emphasis delimiters, creating spurious bold spans that swallow subsequent inline
content. The workaround is to use <code class="language-plaintext highlighter-rouge">:override t</code> on all block-level font-lock
rules so they win over the incorrect inline faces:</p>

<div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre 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
</pre></td><td class="rouge-code"><pre><span class="c1">;; Block-level rules use :override t so block-level faces win over</span>
<span class="c1">;; spurious inline emphasis nodes (the inline parser misreads `*'</span>
<span class="c1">;; list markers as emphasis delimiters).</span>
<span class="ss">:language</span> <span class="ss">'asciidoc</span>
<span class="ss">:override</span> <span class="no">t</span>
<span class="ss">:feature</span> <span class="ss">'list</span>
<span class="o">'</span><span class="p">((</span><span class="nv">ordered_list_marker</span><span class="p">)</span> <span class="nv">@font-lock-constant-face</span>
  <span class="p">(</span><span class="nv">unordered_list_marker</span><span class="p">)</span> <span class="nv">@font-lock-constant-face</span><span class="p">)</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>This doesn’t fix inline elements consumed by the spurious emphasis – that
requires an upstream grammar fix. When you hit grammar-level issues like this,
you either fix them yourself (which means diving into the grammar’s JavaScript
source and C toolchain) or you live with workarounds. Either way, it’s a
reminder that your mode is only as good as the grammar underneath it.</p>

<p>Getting the font-locking right in <code class="language-plaintext highlighter-rouge">asciidoc-mode</code> was probably the most
challenging part of all three projects, precisely because of these grammar
quirks. I also ran into a subtle <code class="language-plaintext highlighter-rouge">treesit</code> behavior: the default font-lock mode
(<code class="language-plaintext highlighter-rouge">:override nil</code>) skips an entire captured range if <em>any</em> position within it
already has a face. So if you capture a parent node like <code class="language-plaintext highlighter-rouge">(inline_macro)</code> and a
child was already fontified, the whole thing gets skipped silently. The fix is
to capture specific child nodes instead:</p>

<div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
</pre></td><td class="rouge-code"><pre><span class="c1">;; BAD: entire node gets skipped if any child is already fontified</span>
<span class="c1">;; (inline_macro) @font-lock-function-call-face</span>

<span class="c1">;; GOOD: capture specific children</span>
<span class="p">(</span><span class="nv">inline_macro</span> <span class="p">(</span><span class="nv">macro_name</span><span class="p">)</span> <span class="nv">@font-lock-function-call-face</span><span class="p">)</span>
<span class="p">(</span><span class="nv">inline_macro</span> <span class="p">(</span><span class="nv">target</span><span class="p">)</span> <span class="nv">@font-lock-string-face</span><span class="p">)</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>These issues took a lot of trial and error to diagnose. The lesson: <strong>budget
extra time for font-locking when working with less mature grammars</strong>.</p>

<h3 id="grammar-versions-and-breaking-changes">Grammar versions and breaking changes</h3>

<p>Grammars evolve, and breaking changes happen. <code class="language-plaintext highlighter-rouge">clojure-ts-mode</code> switched from
the stable grammar to the <a href="https://github.com/sogaiu/tree-sitter-clojure/tree/unstable-20250526">experimental
branch</a>
because the stable version had metadata nodes as children of other nodes, which
caused <code class="language-plaintext highlighter-rouge">forward-sexp</code> and <code class="language-plaintext highlighter-rouge">kill-sexp</code> to behave incorrectly. The experimental
grammar makes metadata standalone nodes, fixing the navigation issues but
requiring all queries to be updated.</p>

<p><code class="language-plaintext highlighter-rouge">neocaml</code> pins to
<a href="https://github.com/tree-sitter/tree-sitter-ocaml/tree/v0.24.0">v0.24.0</a> of the
OCaml grammar. If you don’t pin versions, a grammar update can silently break
your font-locking or indentation.</p>

<p>The takeaway: <strong>always pin your grammar version</strong>, and include a mechanism to
detect outdated grammars. <code class="language-plaintext highlighter-rouge">clojure-ts-mode</code> tests a query that changed between
versions to detect incompatible grammars at startup.</p>

<h3 id="grammar-delivery">Grammar delivery</h3>

<p>Users shouldn’t have to manually clone repos and compile C code to use your
mode. Both <code class="language-plaintext highlighter-rouge">neocaml</code> and <code class="language-plaintext highlighter-rouge">clojure-ts-mode</code> include grammar recipes:</p>

<div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
6
7
</pre></td><td class="rouge-code"><pre><span class="p">(</span><span class="nv">defconst</span> <span class="nv">neocaml-grammar-recipes</span>
  <span class="o">'</span><span class="p">((</span><span class="nv">ocaml</span> <span class="s">"https://github.com/tree-sitter/tree-sitter-ocaml"</span>
           <span class="s">"v0.24.0"</span>
           <span class="s">"grammars/ocaml/src"</span><span class="p">)</span>
    <span class="p">(</span><span class="nv">ocaml-interface</span> <span class="s">"https://github.com/tree-sitter/tree-sitter-ocaml"</span>
                     <span class="s">"v0.24.0"</span>
                     <span class="s">"grammars/interface/src"</span><span class="p">)))</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>On first use, the mode checks <code class="language-plaintext highlighter-rouge">treesit-language-available-p</code> and offers to install
missing grammars via <code class="language-plaintext highlighter-rouge">treesit-install-language-grammar</code>. This works, but requires
a C compiler and Git on the user’s machine, which is not ideal.<sup id="fnref:2"><a href="#fn:2" class="footnote" rel="footnote" role="doc-noteref">2</a></sup></p>

<h3 id="the-emacs-tree-sitter-apis-are-a-moving-target">The Emacs Tree-sitter APIs are a moving target</h3>

<p>The Tree-sitter support in Emacs has been improving steadily, but each version
has its quirks:</p>

<p><strong>Emacs 29</strong> introduced Tree-sitter support but lacked several APIs. For instance,
<code class="language-plaintext highlighter-rouge">treesit-thing-settings</code> (used for structured navigation) doesn’t exist – you
need a fallback:</p>

<div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre><span class="c1">;; Fallback for Emacs 29 (no treesit-thing-settings)</span>
<span class="p">(</span><span class="nb">unless</span> <span class="p">(</span><span class="nb">boundp</span> <span class="ss">'treesit-thing-settings</span><span class="p">)</span>
  <span class="p">(</span><span class="nv">setq-local</span> <span class="nv">forward-sexp-function</span> <span class="nf">#'</span><span class="nv">neocaml-forward-sexp</span><span class="p">))</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p><strong>Emacs 30</strong> added <code class="language-plaintext highlighter-rouge">treesit-thing-settings</code>, sentence navigation, and better
indentation support. But it also had a bug in <code class="language-plaintext highlighter-rouge">treesit-range-settings</code> offsets
(<a href="https://debbugs.gnu.org/cgi/bugreport.cgi?bug=77848">#77848</a>) that broke
embedded parsers, and another in <code class="language-plaintext highlighter-rouge">treesit-transpose-sexps</code> that required
<code class="language-plaintext highlighter-rouge">clojure-ts-mode</code> to disable its Tree-sitter-aware version.</p>

<p><strong>Emacs 31</strong> has a bug in <code class="language-plaintext highlighter-rouge">treesit-forward-comment</code> where an off-by-one error
causes <code class="language-plaintext highlighter-rouge">uncomment-region</code> to leave <code class="language-plaintext highlighter-rouge">*)</code> behind on multi-line OCaml comments. I
had to skip the affected test with a version check:</p>

<div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre><span class="p">(</span><span class="nb">when</span> <span class="p">(</span><span class="nb">&gt;=</span> <span class="nv">emacs-major-version</span> <span class="mi">31</span><span class="p">)</span>
  <span class="p">(</span><span class="nb">signal</span> <span class="ss">'buttercup-pending</span>
          <span class="s">"Emacs 31 treesit-forward-comment bug (off-by-one)"</span><span class="p">))</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The lesson: <strong>test your mode against multiple Emacs versions</strong>, and be prepared
to write version-specific workarounds. CI that runs against Emacs 29, 30, and
snapshot is essential.</p>

<h3 id="no-scm-file-support-yet">No .scm file support (yet)</h3>

<p>Most Tree-sitter grammars ship with <code class="language-plaintext highlighter-rouge">.scm</code> query files for syntax highlighting
(<code class="language-plaintext highlighter-rouge">highlights.scm</code>) and indentation (<code class="language-plaintext highlighter-rouge">indents.scm</code>). Editors like Neovim and
Helix use these directly. Emacs doesn’t – you have to manually translate the
<code class="language-plaintext highlighter-rouge">.scm</code> patterns into <code class="language-plaintext highlighter-rouge">treesit-font-lock-rules</code> and <code class="language-plaintext highlighter-rouge">treesit-simple-indent-rules</code>
calls in Elisp.</p>

<p>This is tedious and error-prone. For example, here’s a rule from the OCaml
grammar’s <code class="language-plaintext highlighter-rouge">highlights.scm</code>:</p>

<div class="language-scheme highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
</pre></td><td class="rouge-code"><pre><span class="c1">;; upstream .scm (used by Neovim, Helix, etc.)</span>
<span class="p">(</span><span class="nf">constructor_name</span><span class="p">)</span> <span class="nv">@type</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>And here’s the Elisp equivalent you’d write for Emacs:</p>

<div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre><span class="c1">;; Emacs equivalent -- wrapped in treesit-font-lock-rules</span>
<span class="ss">:language</span> <span class="ss">'ocaml</span>
<span class="ss">:feature</span> <span class="ss">'type</span>
<span class="o">'</span><span class="p">((</span><span class="nv">constructor_name</span><span class="p">)</span> <span class="nv">@font-lock-type-face</span><span class="p">)</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The query syntax is nearly identical, but you have to wrap everything in
<code class="language-plaintext highlighter-rouge">treesit-font-lock-rules</code> calls, map upstream capture names (<code class="language-plaintext highlighter-rouge">@type</code>) to Emacs
face names (<code class="language-plaintext highlighter-rouge">@font-lock-type-face</code>), assign features, and manage <code class="language-plaintext highlighter-rouge">:override</code>
behavior. You end up maintaining a parallel set of queries that can drift from
upstream. Emacs 31 will introduce
<a href="https://github.com/emacs-mirror/emacs/blob/master/lisp/treesit-x.el#L47"><code class="language-plaintext highlighter-rouge">define-treesit-generic-mode</code></a>
which will make it possible to use <code class="language-plaintext highlighter-rouge">.scm</code> files for font-locking, which should
help significantly. But for now, you’re hand-coding everything.</p>

<h2 id="tips-and-tricks">Tips and tricks</h2>

<h3 id="debugging-font-locking">Debugging font-locking</h3>

<p>When a face isn’t being applied where you expect:</p>

<ol>
  <li>Use <code class="language-plaintext highlighter-rouge">treesit-inspect-mode</code> to verify the node type at point matches your
query.</li>
  <li>Set <code class="language-plaintext highlighter-rouge">treesit--font-lock-verbose</code> to <code class="language-plaintext highlighter-rouge">t</code> to see which rules are firing.</li>
  <li>Check the font-lock feature level – your rule might be in level 4 while the
user has the default level 3. The features are assigned to levels via
<code class="language-plaintext highlighter-rouge">treesit-font-lock-feature-list</code>.</li>
  <li>Remember that <strong>rule order matters</strong>. Without <code class="language-plaintext highlighter-rouge">:override</code>, an earlier rule that
already fontified a region will prevent later rules from applying. This can be
intentional (e.g. builtin types at level 3 take precedence over generic types)
or a source of bugs.</li>
</ol>

<h3 id="use-the-font-lock-levels-wisely">Use the font-lock levels wisely</h3>

<p>Tree-sitter modes define four levels of font-locking via
<code class="language-plaintext highlighter-rouge">treesit-font-lock-feature-list</code>, and the default level in Emacs is 3. It’s
tempting to pile everything into levels 1–3 so users see maximum highlighting
out of the box, but resist the urge. When every token on the screen has a
different color, code starts looking like a Christmas tree and the important
things – keywords, definitions, types – stop standing out.</p>

<p>Less is more here. Here’s how <code class="language-plaintext highlighter-rouge">neocaml</code> distributes features across levels:</p>

<div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre><span class="p">(</span><span class="nv">setq-local</span> <span class="nv">treesit-font-lock-feature-list</span>
            <span class="o">'</span><span class="p">((</span><span class="nv">comment</span> <span class="nv">definition</span><span class="p">)</span>
              <span class="p">(</span><span class="kt">keyword</span> <span class="nb">string</span> <span class="nc">number</span><span class="p">)</span>
              <span class="p">(</span><span class="nv">attribute</span> <span class="nv">builtin</span> <span class="nv">constant</span> <span class="k">type</span><span class="p">)</span>
              <span class="p">(</span><span class="nv">operator</span> <span class="nv">bracket</span> <span class="nv">delimiter</span> <span class="nv">variable</span> <span class="k">function</span><span class="p">)))</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>And <code class="language-plaintext highlighter-rouge">clojure-ts-mode</code> follows the same philosophy:</p>

<div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre><span class="p">(</span><span class="nv">setq-local</span> <span class="nv">treesit-font-lock-feature-list</span>
            <span class="o">'</span><span class="p">((</span><span class="nv">comment</span> <span class="nv">definition</span><span class="p">)</span>
              <span class="p">(</span><span class="kt">keyword</span> <span class="nb">string</span> <span class="nb">char</span> <span class="nc">symbol</span> <span class="nv">builtin</span> <span class="k">type</span><span class="p">)</span>
              <span class="p">(</span><span class="nv">constant</span> <span class="nc">number</span> <span class="k">quote</span> <span class="nv">metadata</span> <span class="nv">doc</span> <span class="nv">regex</span><span class="p">)</span>
              <span class="p">(</span><span class="nv">bracket</span> <span class="nv">deref</span> <span class="k">function</span> <span class="nv">tagged-literals</span><span class="p">)))</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>The pattern is the same: essentials first, progressively more detail at higher
levels. This way the default experience (level 3) is clean and readable, and
users who want the full rainbow can bump <code class="language-plaintext highlighter-rouge">treesit-font-lock-level</code> to 4. Better
yet, they can use <code class="language-plaintext highlighter-rouge">treesit-font-lock-recompute-features</code> to cherry-pick
individual features regardless of level:</p>

<div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
5
</pre></td><td class="rouge-code"><pre><span class="c1">;; Enable 'function' (level 4) without enabling all of level 4</span>
<span class="p">(</span><span class="nv">treesit-font-lock-recompute-features</span> <span class="o">'</span><span class="p">(</span><span class="k">function</span><span class="p">)</span> <span class="no">nil</span><span class="p">)</span>

<span class="c1">;; Disable 'bracket' even if the user's level would include it</span>
<span class="p">(</span><span class="nv">treesit-font-lock-recompute-features</span> <span class="no">nil</span> <span class="o">'</span><span class="p">(</span><span class="nv">bracket</span><span class="p">))</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>This gives users fine-grained control without requiring mode authors to
anticipate every preference.</p>

<h3 id="debugging-indentation">Debugging indentation</h3>

<p>Indentation issues are harder to diagnose because they depend on tree structure,
rule ordering, and anchor resolution:</p>

<ol>
  <li>Set <code class="language-plaintext highlighter-rouge">treesit--indent-verbose</code> to <code class="language-plaintext highlighter-rouge">t</code> – this logs which rule matched for each
line, what anchor was computed, and the final column.</li>
  <li>Use <code class="language-plaintext highlighter-rouge">treesit-explore-mode</code> to understand the parent chain. The key question
is always: “what is the parent node, and which rule matches it?”</li>
  <li>
    <p>Remember that <strong>rule order matters</strong> for indentation too – the first matching
rule wins. A typical set of rules reads top to bottom from most specific to
most general:</p>

    <div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre 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><td class="rouge-code"><pre><span class="c1">;; Closing delimiters align with the opening construct</span>
<span class="p">((</span><span class="nv">node-is</span> <span class="s">")"</span><span class="p">)</span> <span class="nv">parent-bol</span> <span class="mi">0</span><span class="p">)</span>
<span class="p">((</span><span class="nv">node-is</span> <span class="s">"end"</span><span class="p">)</span> <span class="nv">parent-bol</span> <span class="mi">0</span><span class="p">)</span>

<span class="c1">;; then/else clauses align with their enclosing if</span>
<span class="p">((</span><span class="nv">node-is</span> <span class="s">"then_clause"</span><span class="p">)</span> <span class="nv">parent-bol</span> <span class="mi">0</span><span class="p">)</span>
<span class="p">((</span><span class="nv">node-is</span> <span class="s">"else_clause"</span><span class="p">)</span> <span class="nv">parent-bol</span> <span class="mi">0</span><span class="p">)</span>

<span class="c1">;; Bodies inside then/else are indented</span>
<span class="p">((</span><span class="nv">parent-is</span> <span class="s">"then_clause"</span><span class="p">)</span> <span class="nv">parent-bol</span> <span class="nv">neocaml-indent-offset</span><span class="p">)</span>
<span class="p">((</span><span class="nv">parent-is</span> <span class="s">"else_clause"</span><span class="p">)</span> <span class="nv">parent-bol</span> <span class="nv">neocaml-indent-offset</span><span class="p">)</span>
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
  <li>
    <p>Watch out for the <strong>empty-line problem</strong>: when the cursor is on a blank line,
Tree-sitter has no node at point. The indentation engine falls back to the root
<code class="language-plaintext highlighter-rouge">compilation_unit</code> node as the parent, which typically matches the top-level
rule and gives column 0. In neocaml I solved this with a <code class="language-plaintext highlighter-rouge">no-node</code> rule that
looks at the previous line’s last token to decide indentation:</p>

    <div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre><span class="p">(</span><span class="nv">no-node</span> <span class="nv">prev-line</span> <span class="nv">neocaml--empty-line-offset</span><span class="p">)</span>
</pre></td></tr></tbody></table></code></pre></div>    </div>
  </li>
</ol>

<h3 id="build-a-comprehensive-test-suite">Build a comprehensive test suite</h3>

<p>This is the single most important piece of advice. Font-lock and indentation are
easy to break accidentally, and manual testing doesn’t scale. Both projects use
<a href="https://github.com/jorgenschaefer/emacs-buttercup">Buttercup</a> (a BDD testing
framework for Emacs) with custom test macros.</p>

<p><strong>Font-lock tests</strong> insert code into a buffer, run <code class="language-plaintext highlighter-rouge">font-lock-ensure</code>, and assert
that specific character ranges have the expected face:</p>

<div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre><span class="p">(</span><span class="nv">when-fontifying-it</span> <span class="s">"fontifies let-bound functions"</span>
  <span class="p">(</span><span class="s">"let greet name = ..."</span>
   <span class="p">(</span><span class="mi">5</span> <span class="mi">9</span> <span class="nv">font-lock-function-name-face</span><span class="p">)))</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p><strong>Indentation tests</strong> insert code, run <code class="language-plaintext highlighter-rouge">indent-region</code>, and assert the result
matches the expected indentation:</p>

<div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
4
</pre></td><td class="rouge-code"><pre><span class="p">(</span><span class="nv">when-indenting-it</span> <span class="s">"indents a match expression"</span>
  <span class="s">"match x with"</span>
  <span class="s">"| 0 -&gt; \"zero\""</span>
  <span class="s">"| n -&gt; string_of_int n"</span><span class="p">)</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p><strong>Integration tests</strong> load real source files and verify that both font-locking
and indentation survive <code class="language-plaintext highlighter-rouge">indent-region</code> on the full file. This catches
interactions between rules that unit tests miss.</p>

<p><code class="language-plaintext highlighter-rouge">neocaml</code> has 200+ automated tests and <code class="language-plaintext highlighter-rouge">clojure-ts-mode</code> has even more.
Investing in test infrastructure early pays off enormously – I can refactor
indentation rules with confidence because the suite catches regressions
immediately.</p>

<h4 id="a-personal-story-on-testing-roi">A personal story on testing ROI</h4>

<p>When I became the maintainer of
<a href="https://github.com/clojure-emacs/clojure-mode">clojure-mode</a> many years ago, I
really struggled with making changes. There were no font-lock or indentation
tests, so every change was a leap of faith – you’d fix one thing and break three
others without knowing until someone filed a bug report. I spent years working
on a testing approach I was happy with, alongside many great contributors, and
the return on investment was massive.</p>

<p>The same approach – almost the same test macros – carried over directly to
<code class="language-plaintext highlighter-rouge">clojure-ts-mode</code> when we built the Tree-sitter version. And later I reused the
pattern again in <code class="language-plaintext highlighter-rouge">neocaml</code> and <code class="language-plaintext highlighter-rouge">asciidoc-mode</code>. One investment in testing
infrastructure, four projects benefiting from it.</p>

<p>I know that automated tests, for whatever reason, never gained much traction in
the Emacs community. Many popular packages have no tests at all. I hope stories
like this convince you that investing in tests is really important and pays off
– not just for the project where you write them, but for every project you build
after.</p>

<h3 id="pre-compile-queries">Pre-compile queries</h3>

<p>This one is specific to <code class="language-plaintext highlighter-rouge">clojure-ts-mode</code> but applies broadly: compiling
Tree-sitter queries at runtime is expensive. If you’re building queries
dynamically (e.g. with <code class="language-plaintext highlighter-rouge">treesit-font-lock-rules</code> called at mode init time),
consider pre-compiling them as <code class="language-plaintext highlighter-rouge">defconst</code> values. This made a noticeable
difference in <code class="language-plaintext highlighter-rouge">clojure-ts-mode</code>’s startup time.</p>

<h2 id="a-note-on-naming">A note on naming</h2>

<p>The Emacs community has settled on a <code class="language-plaintext highlighter-rouge">-ts-mode</code> suffix convention for
Tree-sitter-based modes: <code class="language-plaintext highlighter-rouge">python-ts-mode</code>, <code class="language-plaintext highlighter-rouge">c-ts-mode</code>, <code class="language-plaintext highlighter-rouge">ruby-ts-mode</code>, and so
on. This makes sense when both a legacy mode and a Tree-sitter mode coexist in
Emacs core – users need to choose between them. But I think the convention is
being applied too broadly, and I’m afraid the resulting name fragmentation will
haunt the community for years.</p>

<p>For new packages that don’t have a legacy counterpart, the <code class="language-plaintext highlighter-rouge">-ts-mode</code> suffix is
unnecessary. I named my packages <code class="language-plaintext highlighter-rouge">neocaml</code> (not <code class="language-plaintext highlighter-rouge">ocaml-ts-mode</code>) and
<code class="language-plaintext highlighter-rouge">asciidoc-mode</code> (not <code class="language-plaintext highlighter-rouge">adoc-ts-mode</code>) because there was no prior <code class="language-plaintext highlighter-rouge">neocaml-mode</code>
or <code class="language-plaintext highlighter-rouge">asciidoc-mode</code> to disambiguate from. The <code class="language-plaintext highlighter-rouge">-ts-</code> infix is an implementation
detail that shouldn’t leak into the user-facing name. Will we rename everything
again when Tree-sitter becomes the default and the non-TS variants are removed?</p>

<p>Be bolder with naming. If you’re building something new, give it a name that
makes sense on its own merits, not one that encodes the parsing technology in the
package name.</p>

<h2 id="the-road-ahead">The road ahead</h2>

<p>I think the full transition to Tree-sitter in the Emacs community will take
3–5 years, optimistically. There are hundreds of major modes out there, many
maintained by a single person in their spare time. Converting a mode from regex
to Tree-sitter isn’t just a mechanical translation – you need to understand the
grammar, rewrite font-lock and indentation rules, handle version compatibility,
and build a new test suite. That’s a lot of work.</p>

<p>Interestingly, this might be one area where agentic coding tools can genuinely
help. The structure of Tree-sitter-based major modes is fairly uniform: grammar
recipes, font-lock rules, indentation rules, navigation settings, imenu. If you
give an AI agent a grammar and a reference to a high-quality mode like
<code class="language-plaintext highlighter-rouge">clojure-ts-mode</code>, it could probably scaffold a reasonable new mode fairly
quickly. The hard parts – debugging grammar quirks, handling edge cases, getting
indentation <em>just right</em> – would still need human attention, but the boilerplate
could be automated.</p>

<p>Still, knowing the Emacs community, I wouldn’t be surprised if a full migration
never actually completes. Many old-school modes work perfectly fine, their
maintainers have no interest in Tree-sitter, and “if it ain’t broke, don’t fix
it” is a powerful force. And that’s okay – diversity of approaches is part of
what makes Emacs Emacs.</p>

<h2 id="closing-thoughts">Closing thoughts</h2>

<p>Tree-sitter is genuinely great for building Emacs major modes. The code is
simpler, the results are more accurate, and incremental parsing means everything
stays fast even on large files. I wouldn’t go back to regex-based font-locking
willingly.</p>

<p>But it’s not magical. Grammars are inconsistent across languages, the Emacs APIs
are still maturing, you can’t reuse <code class="language-plaintext highlighter-rouge">.scm</code> files (yet), and you’ll hit
version-specific bugs that require tedious workarounds. The testing story is
better than with regex modes – tree structures are more predictable than regex
matches – but you still need a solid test suite to avoid regressions.</p>

<p>If you’re thinking about writing a Tree-sitter-based major mode, do it. The
ecosystem needs more of them, and the experience of working with syntax trees
instead of regexes is genuinely enjoyable. Just go in with realistic
expectations, pin your grammar versions, test against multiple Emacs releases,
and build your test suite early.</p>

<p>Anyways, I wish there was an article like this one when I was starting out
with <code class="language-plaintext highlighter-rouge">clojure-ts-mode</code> and <code class="language-plaintext highlighter-rouge">neocaml</code>, so there you have it. I hope that
the lessons I’ve learned along the way will help build better modes
with Tree-sitter down the road.</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 excellent <a href="https://github.com/sogaiu/tree-sitter-clojure/blob/master/doc/scope.md">scope discussion</a> in the tree-sitter-clojure repo for the rationale. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">&#8617;&#xfe0e;</a></p>
    </li>
    <li id="fn:2">
      <p>There’s ongoing discussion in the Emacs community about distributing pre-compiled grammar binaries, but nothing concrete yet. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">&#8617;&#xfe0e;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Bozhidar Batsov</name></author><category term="Emacs" /><category term="OCaml" /><category term="Clojure" /><category term="AsciiDoc" /><summary type="html"><![CDATA[Over the past year I’ve been spending a lot of time building Tree-sitter-powered major modes for Emacs – clojure-ts-mode (as co-maintainer), neocaml (from scratch), and asciidoc-mode (also from scratch). Between the three projects I’ve accumulated enough knowledge (and battle scars) to write about the experience. This post distills the key lessons for anyone thinking about writing a Tree-sitter-based major mode, or curious about what it’s actually like.]]></summary></entry><entry><title type="html">Setting up Emacs for OCaml Development: Neocaml Edition</title><link href="https://batsov.com/articles/2026/02/24/setting-up-emacs-for-ocaml-development-neocaml-edition/" rel="alternate" type="text/html" title="Setting up Emacs for OCaml Development: Neocaml Edition" /><published>2026-02-24T12:00:00+02:00</published><updated>2026-02-24T12:00:00+02:00</updated><id>https://batsov.com/articles/2026/02/24/setting-up-emacs-for-ocaml-development-neocaml-edition</id><content type="html" xml:base="https://batsov.com/articles/2026/02/24/setting-up-emacs-for-ocaml-development-neocaml-edition/"><![CDATA[<p>A few years ago I wrote about <a href="/articles/2022/08/23/setting-up-emacs-for-ocaml-development/">setting up Emacs for OCaml
development</a>.
Back then the recommended stack was <code class="language-plaintext highlighter-rouge">tuareg-mode</code> + <code class="language-plaintext highlighter-rouge">merlin-mode</code>, with Merlin
providing the bulk of the IDE experience. A lot has changed since then – the
OCaml tooling has evolved considerably, and I’ve been working on some new tools
myself. Time for an update.</p>

<h2 id="the-new-stack">The New Stack</h2>

<p>Here’s what I recommend today:</p>

<ul>
  <li><a href="https://github.com/bbatsov/neocaml">neocaml</a> instead of <code class="language-plaintext highlighter-rouge">tuareg-mode</code></li>
  <li><a href="https://github.com/tarides/ocaml-eglot">ocaml-eglot</a> instead of <code class="language-plaintext highlighter-rouge">merlin-mode</code></li>
  <li><a href="https://www.gnu.org/software/emacs/manual/html_mono/eglot.html">Eglot</a> (built into Emacs 29+) as the LSP client</li>
</ul>

<p>The key shift is from Merlin’s custom protocol to LSP.
<a href="https://github.com/ocaml/ocaml-lsp">ocaml-lsp-server</a> has matured
significantly since my original article – it’s no longer a thin wrapper around
Merlin. It now offers project-wide renaming, semantic highlighting, Dune RPC
integration, and OCaml-specific extensions like pattern match generation and
typed holes. <code class="language-plaintext highlighter-rouge">ocaml-eglot</code> is a lightweight Emacs package by
<a href="https://tarides.com/">Tarides</a> that bridges Eglot with these OCaml-specific
LSP extensions, giving you the full Merlin feature set through a standardized
protocol.</p>

<p>And <code class="language-plaintext highlighter-rouge">neocaml</code> is my own Tree-sitter-powered OCaml major mode – modern, lean,
and built for the LSP era. You can read more about it in the <a href="/articles/2026/02/14/neocaml-0-1-ready-for-action/">0.1 release
announcement</a>.</p>

<h2 id="the-essentials">The Essentials</h2>

<p>First, install the server-side tools:</p>

<div class="language-console highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
</pre></td><td class="rouge-code"><pre><span class="gp">$</span><span class="w"> </span>opam <span class="nb">install </span>ocaml-lsp-server
</pre></td></tr></tbody></table></code></pre></div></div>

<blockquote class="prompt-tip">
  <p>You no longer need to install <code class="language-plaintext highlighter-rouge">merlin</code> separately – <code class="language-plaintext highlighter-rouge">ocaml-lsp-server</code>
vendors it internally.</p>
</blockquote>

<p>Then set up Emacs:</p>

<div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre 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
12
</pre></td><td class="rouge-code"><pre><span class="c1">;; Modern Tree-sitter-powered OCaml major mode</span>
<span class="p">(</span><span class="nb">use-package</span> <span class="nv">neocaml</span>
  <span class="ss">:ensure</span> <span class="no">t</span><span class="p">)</span>

<span class="c1">;; Major mode for editing Dune project files</span>
<span class="p">(</span><span class="nb">use-package</span> <span class="nv">dune</span>
  <span class="ss">:ensure</span> <span class="no">t</span><span class="p">)</span>

<span class="c1">;; OCaml-specific LSP extensions via Eglot</span>
<span class="p">(</span><span class="nb">use-package</span> <span class="nv">ocaml-eglot</span>
  <span class="ss">:ensure</span> <span class="no">t</span>
  <span class="ss">:hook</span> <span class="p">(</span><span class="nv">neocaml-mode</span> <span class="o">.</span> <span class="nv">ocaml-eglot-setup</span><span class="p">))</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>That’s it. Eglot ships with Emacs 29+, so there’s nothing extra to install for
the LSP client itself. When you open an OCaml file, Eglot will automatically
start <code class="language-plaintext highlighter-rouge">ocaml-lsp-server</code> and you’ll have completion, type information, code
navigation, diagnostics, and all the other goodies you’d expect.</p>

<p>Compare this to the old setup – no more <code class="language-plaintext highlighter-rouge">merlin-mode</code>, <code class="language-plaintext highlighter-rouge">merlin-eldoc</code>,
<code class="language-plaintext highlighter-rouge">flycheck-ocaml</code>, or manual Company configuration. LSP handles all of it
through a single, uniform interface.</p>

<h2 id="the-toplevel">The Toplevel</h2>

<p><code class="language-plaintext highlighter-rouge">neocaml</code> includes built-in REPL integration via <code class="language-plaintext highlighter-rouge">neocaml-repl-minor-mode</code>. The
basics work well:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">C-c C-z</code> – start or switch to the OCaml toplevel</li>
  <li><code class="language-plaintext highlighter-rouge">C-c C-c</code> – send the current definition</li>
  <li><code class="language-plaintext highlighter-rouge">C-c C-r</code> – send the selected region</li>
  <li><code class="language-plaintext highlighter-rouge">C-c C-b</code> – send the entire buffer</li>
</ul>

<p>If you want <code class="language-plaintext highlighter-rouge">utop</code> specifically, you’re still better off using
<a href="https://github.com/ocaml-community/utop">utop.el</a> alongside <code class="language-plaintext highlighter-rouge">neocaml</code>. Its
main advantage is that you get code completion inside the <code class="language-plaintext highlighter-rouge">utop</code> REPL within
Emacs – something <code class="language-plaintext highlighter-rouge">neocaml</code>’s built-in REPL integration doesn’t provide:</p>

<div class="language-emacs-lisp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><table class="rouge-table"><tbody><tr><td class="rouge-gutter gl"><pre class="lineno">1
2
3
</pre></td><td class="rouge-code"><pre><span class="p">(</span><span class="nb">use-package</span> <span class="nv">utop</span>
  <span class="ss">:ensure</span> <span class="no">t</span>
  <span class="ss">:hook</span> <span class="p">(</span><span class="nv">neocaml-mode</span> <span class="o">.</span> <span class="nv">utop-minor-mode</span><span class="p">))</span>
</pre></td></tr></tbody></table></code></pre></div></div>

<p>This will shadow <code class="language-plaintext highlighter-rouge">neocaml</code>’s REPL keybindings with <code class="language-plaintext highlighter-rouge">utop</code>’s, which is the
intended behavior.</p>

<p>That said, as I’ve grown more comfortable with OCaml I find myself using the
toplevel less and less. These days I rely more on a test-driven workflow –
write a test, run it, iterate. In particular I’m partial to the workflow
described in <a href="https://discuss.ocaml.org/t/whats-your-development-workflow/10358/7">this OCaml Discuss
thread</a> –
running <code class="language-plaintext highlighter-rouge">dune runtest</code> continuously and writing expect tests for quick feedback.
It’s a more structured approach that scales better than REPL-driven development,
especially as your projects grow.</p>

<h2 id="give-neocaml-a-try">Give neocaml a Try</h2>

<p>If you’re an OCaml programmer using Emacs, I’d love for you to take
<a href="https://github.com/bbatsov/neocaml">neocaml</a> for a spin. It’s available on
MELPA, so getting started is just an <code class="language-plaintext highlighter-rouge">M-x package-install</code> away. The project is
still young and I’m actively working on it – your feedback, bug reports, and
pull requests are invaluable. Let me know what works, what doesn’t, and what
you’d like to see next.</p>

<p>That’s all I have for you today. Keep hacking!</p>]]></content><author><name>Bozhidar Batsov</name></author><category term="Emacs" /><category term="OCaml" /><summary type="html"><![CDATA[A few years ago I wrote about setting up Emacs for OCaml development. Back then the recommended stack was tuareg-mode + merlin-mode, with Merlin providing the bulk of the IDE experience. A lot has changed since then – the OCaml tooling has evolved considerably, and I’ve been working on some new tools myself. Time for an update.]]></summary></entry></feed>