Learning Fennel from Scratch to Develop Neovim Plugins
by Laurence Chen
As a Neovim user writing Clojure, I often watch my colleagues modifying Elisp to create plugins—for example, setting a shortcut key to convert Hiccup-formatted data in the editor into HTML. My feeling is quite complex. On one hand, I just can’t stand Emacs; I’ve tried two or three times, but I could not learn it well. On the other hand, if developing Neovim plugins means choosing between Lua and VimScript, neither of those options excites me. Despite these challenges, I envy those who can extend their editor using Lisp.
Wouldn’t it be great if there were a Lisp I could use to develop my own Neovim plugins? One day, I discovered Fennel, which compiles to Lua. Great! Now, Neovim actually has a Lisp option. But then came the real challenge—getting started with Fennel and Neovim development was much harder than I expected.
Here’s how I initially failed.
I read the aniseed GitHub repository’s README and followed along with the tutorial. Some Clojure-like functions seemed to work, but the critical Conjure command, jump to definition, didn’t. Next, I tried setting a seemingly simpler goal and pushed forward. However, whenever I encountered problems, I often didn’t know how to handle them. Eventually, I put the whole thing aside.
Reducing Runtime Dependencies
My second attempt at learning Fennel was triggered by a specific issue: when developing ClojureScript with Conjure, I had to manually run the command :ConjureShadowSelect [build-id]
to enable interactive development. But I kept forgetting that command.
One day, I found that others had the same problem. Someone proposed a solution using Neovim auto commands:
" Define a function `AutoConjureSelect` to auto-select
function! AutoConjureSelect()
let shadow_build=system("ps aux | grep 'shadow-cljs watch' | head -1 | sed -E 's/.*?shadow-cljs watch //' | tr -d '\n'")
let cmd='ConjureShadowSelect ' . shadow_build
execute cmd
endfunction
command! AutoConjureSelect call AutoConjureSelect()
" Trigger `AutoConjureSelect` whenever a cljs file is opened
autocmd BufReadPost *.cljs :AutoConjureSelect
Unfortunately, this solution didn’t work on my machine, likely due to OS differences. So I wrote a Babashka script to replace system(...)
:
#!/usr/bin/env bb
(require '[clojure.edn :as edn])
(require '[clojure.java.io :as io])
(def shadow-config (edn/read-string (slurp (io/file "shadow-cljs.edn"))))
(def build-ids (map name (keys (:builds shadow-config)))) ;; Convert to strings
(print (first build-ids))
Seeing how simple this Babashka script was, I felt inspired to take on Fennel again. If I could rewrite this in Fennel, my editor configuration would rely solely on the Neovim runtime—no extra Babashka runtime required.
After a few days of struggling, I barely made it.
I wrote a Fennel script at .config/nvim/fnl/auto-conjure.fnl
, roughly the same length as the Babashka script:
(local {: autoload} (require :nfnl.module))
(local a (autoload :nfnl.core))
(local {: decode} (autoload :edn))
(local nvim vim.api)
(fn shadow-cljs-content []
(a.slurp :shadow-cljs.edn))
(fn build-key [tbl]
(a.first (a.keys (a.get tbl :builds))))
(fn shadow_build_id []
(build-key (decode (shadow-cljs-content))))
{: shadow_build_id}
I then replaced system(...)
with luaeval("require('auto-conjure').shadow_build_id()")
, creating an auto Conjure command that relied only on the Neovim runtime.
Here is the full implementation and discussion.
How to Learn Fennel and Develop Neovim Plugins?
I finally have a grasp of writing Fennel. After some detours, I found the following learning sequence to be effective:
-
Spend time reading the documentation on the Fennel website. The Setup Guide, Tutorial, Rationale, Lua Primer, Reference, and Fennel from Clojure are particularly helpful.
-
Set up Conjure, Fennel syntax highlighting, and s-expression editing for Fennel.
-
Choose a small enough project—preferably one that you can first implement in Babashka before rewriting in Fennel. Keep it simple; it doesn’t have to be a full-fledged plugin—just part of a feature. You can start by placing an
.fnl
file in.config/nvim/fnl/
and loading it in.config/nvim/init.vim
vialuaeval()
. -
While developing, you’ll encounter many challenges. The key is to learn how to debug effectively in Neovim. Writing plugins in Fennel isn’t difficult due to syntax or semantics—those are easy to pick up. The real challenge is Neovim’s runtime, which is a unique environment. You’ll need to memorize new commands to inspect the runtime state and understand its core workings to make reasonable debugging assumptions.
Common Pitfalls
One major trap I fell into was trying to reuse others’ Neovim configurations or convert my entire config to Fennel at once. My system’s configuration differed slightly from others’, so copying their Neovim setup led to an overwhelming number of bugs.
Additionally, after trying several Neovim plugins, I found that many didn’t suit my workflow. In the end, I stuck with my VimScript-based Neovim config, making only minor adjustments for Fennel development.
Aniseed is Friendly for Clojure Developers, but nfnl Fits the Lua Ecosystem Better
Originally, Conjure’s author, Olical, wrote Conjure in Clojure and used Neovim’s msgpack RPC for communication. Later, he rewrote it in Fennel. Since vanilla Fennel lacked a Clojure-like namespace, he created the aniseed compiler, which introduced module
, defn
, and def
macros.
Developing with aniseed feels great, especially for Clojure developers—it’s almost like renaming namespace
to module
, replacing require
with autoload
, and keeping defn
and def
.
However, over time, Olical realized that forcing Fennel code to depend on Aniseed’s design wasn’t ideal. He then developed nfnl, a new Fennel compiler with a different coding style. In nfnl, functions are declared with fn
, variables with local
, and instead of module
, files follow Lua’s convention of returning a table at the end. This makes nfnl less Clojurish but more compatible with Lua’s ecosystem.
One confusing point: even in the latest Conjure version (4.53.0)
, the variable vim.g.conjure#filetype#fennel
still points to "conjure.client.fennel.aniseed"
. After checking the release notes, I found this clarification:
I’m still working on the new Fennel client that runs through nfnl and supports REPL-driven development with pure Fennel (no module or def macros required!), but that’s still in the works.
Debug experience in Neovim runtime
During my learning process, the biggest challenge I encountered was my lack of understanding of the Neovim runtime. It felt like dealing with a black box—unexpected things happened, and I couldn’t immediately figure out a reasonable way to handle them. Here are some examples:
Plugin not automatically enabled
This type of issue is relatively simple. Checking the plugin’s source code and modifying global variables usually solves the problem.
Fennel is a lesser-known Lisp in the Lisp family, so some generic Lisp plugins might not automatically recognize it, causing them not to activate. The design convention in Neovim plugins typically addresses such issues by setting global variables. When you examine the plugin’s source code, you can often find some global variables prefixed with g:
.
For example, in the guns/vim-sexp
plugin, I modified a global variable, and s-expression editing was enabled for Fennel:
let g:sexp_filetypes = 'clojure,scheme,lisp,fennel'
Plugin enabled despite not being in init.vim
Earlier, I took a detour by using someone else’s Neovim config. Their init.lua
installed several unfamiliar plugins. Later, when I abandoned init.lua
and switched back to my own init.vim
, the plugins installed via init.lua
were still present. Additionally, some Neovim behaviors were unexpectedly altered.
How should this be handled? After some research, I discovered the Neovim Ex command :scriptnames
, which lists all currently loaded plugins. After identifying and removing the unintended plugins, everything returned to normal.
Below is a list of Ex commands used for observing the state.
nfnl automatic compilation not occurring
Fennel files placed under .config/nvim/fnl
can be automatically compiled using either aniseed or nfnl. However, I encountered a strange issue—when using aniseed, automatic compilation worked as expected after configuration, but with nfnl, it didn’t trigger at all.
Thus, my debugging steps became:
- Check whether the nfnl module is loaded successfully.
This can be verified using the Ex command :lua print(require('nfnl'))
. If you get a result like table: 0x0102671b80
, it means the module has been successfully loaded. If the module fails to load, an error message will be displayed instead.
- Check whether manual compilation works.
Open an .fnl
file and execute the Ex command :NfnlCompileFile
. If successful, you should see output like:
Compilation complete.
{:destination-path "$path_to_nvim/lua/auto-conjure.lua"
:source-path "$path_to_nvim/fnl/auto-conjure.fnl"
:status "ok"}
If .nfnl.fnl
is misconfigured, you might get:
Compilation complete.
{:source-path "$path_to_nvim/fnl/auto-conjure.fnl"
:status "path-is-not-in-source-file-patterns"}
After performing these checks and finding no issues, I narrowed the problem down to auto command execution. Eventually, I discovered that an auto-formatting auto command I had set up was conflicting with the nfnl module’s auto command.
My original auto command was:
function! Fnlfmt()
!fnlfmt --fix %
" :e is to force reload the file after it got formatted.
:e
endfunction
autocmd BufWritePost *.fnl call Fnlfmt()
Once I identified the issue, the solution was straightforward—I modified the auto command to perform both auto-formatting and compilation:
augroup FennelOnSave
autocmd!
autocmd BufWritePost *.fnl call Fnlfmt() | NfnlCompileFile
augroup END
Conclusion
For me, learning Fennel to develop Neovim plugins was not just about learning a language or a development environment—it was an opportunity to rethink how to effectively learn new technologies from scratch.
This journey provided several key takeaways:
-
Learning strategy is crucial — Instead of trying to refactor my entire Neovim setup at the start, I should have started with a small, well-defined project and learned progressively. An ideal project should be small enough to implement in another language first. Additionally, reading the official documentation with patience often yields unexpected benefits.
-
Understanding the Neovim runtime is essential — Fennel’s syntax itself isn’t difficult; the real challenge lies in interacting with the Neovim runtime. Mastering how to observe Neovim’s internal state (e.g., using Ex commands) is critical.
-
The value of a standardized ecosystem — During my research, I also studied Conjure’s transition from aniseed to nfnl. This was a large-scale migration, but I appreciated nfnl’s design, which helped me better understand the significance of standardized ecosystems for code reuse.
For Clojure developers already using Neovim and Conjure, learning Fennel won’t be a steep challenge. If you haven’t tried it yet, why not eat your own dog food—it actually tastes pretty good!